--- name: arandu-module description: The model and data of an Arandu (Go) application -- an entity, its table, the generated query, migrations, factories, seeders, scopes, relations, the rules of the entity itself and the service that orders a use case over them. Use when the request is to "create a model", "add a table", "add a column", "scaffold CRUD", "add invoices", "a record under another record", "add a state transition", "write a seeder", or when a query, a migration or `model:build` is involved. Covers aru make:module (with --tenant and --parent), aru generate and its specification, make:model, make:migration, make:service, model:build and the custom blocks that survive regeneration. license: MIT --- # The model and its data ## When to use A new kind of record, a column, a transition of a record's state, a query the generated builder says, a factory or a seeder, or the service method that reads and writes them. Who may run the method is `arandu-policy`; what calls the service over HTTP is `arandu-http`. ## Before you start - Read the example's model, `app/Models/Note.go`, and its service, `app/Services/NoteService.go`. `app/Models/Comment.go` is the same shape generated under a parent. - Answer the ownership questions in `arandu-ecosystem` first: a balance, a role, a tag or rendered Markdown already has an owner and gets no table here. ## Contracts and imports | piece | where | contract | | --- | --- | --- | | entity | `app/Models/.go` | a struct embedding `model.Model` (`github.com/arandu-io/hesape/database/model`) with `db:` tags, and `var Table = model.NewTable(model.TableSpec{...})` beside it | | query | `app/Models/Query.go` | generated by `aru model:build`: `models.(db)`, `*Query`, `Collection`. Every terminal -- `Get`, `First`, `FindOrFail`, `SimplePaginate`, `Count`, `Exists` -- takes an `auth.Grant` | | write | the entity | `Save(ctx, g)` and `Delete(ctx, g)` on a row that came from a query or from `models.(db).New()` | | rule of the entity | the custom block of `.go` | a method that changes only this row's fields; no database, network, clock or Grant | | scope | the custom block of `.go` | a method on `*Query` | | relation | an `init` in `.go` | `Table.Relate(name, func(*model.Model) model.Relation)` | | migration | `database/migrations/.go` | registers itself in `init`; `Up` and `Down` over `schema.Blueprint` | | factory, seeder | `database/factories`, `database/seeders` | `factories.(db).Count(n).Create(ctx, g)`; a seeder runs twice safely | | service | `app/Services/Service.go` | `NewService(db *database.DB)`; methods `(ctx, actor auth.Subject, in requests.X | id string)`; a transaction is `database.Transaction(ctx, db, fn)` | ## Procedure 1. **Generate the resource.** `aru make:module note --fields "title:string!,body:text,pinned:bool" --tenant` writes the migration, the entity and its query, the factory, the policy, the request, the service, the controller, the four screens and the tests, and prints the wiring. Under a parent, add `--parent=notes`: the service then loads the parent through the parent's own service and filters by the row it loaded, which is how `CommentService` reads a note. 2. **Or write a specification** when the module needs permissions or a description the flags cannot say: `aru schema` prints the schema, `aru generate invoice.yaml --check` reports every problem at once, and `aru generate invoice.yaml` writes the tree and keeps the specification in `database/specs/`. 3. **Change a table with a new migration**, never by editing one that ran: `aru make:migration add_published_at_to_notes --table=notes --fields "published_at:timestamp"`. A column added to a table that has rows is nullable or has a default, because the previous binary is still inserting during a rollout. 4. **Put the entity's rules in the entity.** A transition is a method on the entity that takes what it needs -- the time included -- and refuses a state it does not accept with an error that carries `HTTPStatus() int`, as `Note.Publish` does with `ErrNoteAlreadyPublished` (409). 5. **Order the use case in the service**: validate the request, read the row through the Grant, authorize the row (`arandu-policy`), apply the entity's rule, save with the Grant, and store the events of the write in the same transaction (`arandu-async`). `NoteService.Publish` is that order, in full. 6. **Run `aru model:build`** after any change to an entity, and never edit what it writes. ## Commands - `aru make:module --fields "..." --tenant [--parent=] [--force] [--dry-run]` - `aru generate --check`, `aru generate `, `aru schema` - `aru make:model --fields "..." [--tenant] [--migration] [--factory] [--seed] [--policy] [--requests] [--controller] [--all]` - `aru make:migration --create=` or `--table=
`, with `--fields` - `aru make:service `, `aru make:factory `, `aru make:seeder `, `aru make:enum --values draft,sent` - `aru model:build`, and `aru model:build --check` in a pipeline - `aru migrate`, `aru migrate:rollback`, `aru migrate:status`, `aru migrate:fresh` (development only), `aru db:seed` The column types are a closed set -- `string` `text` `int` `decimal` `money` `bool` `date` `timestamp` `uuid` `email` -- and so are a specification's actions: `view` `create` `update` `delete` `list`. `money` is an integer of cents. ## Example A read through the generated query and a write through the entity's rule, the two halves of every service method: ```go compile package example import ( "context" "time" "github.com/arandu-io/hesape/auth" "github.com/arandu-io/hesape/database" models "/app/Models" policies "/app/Policies" ) // PinnedDrafts reads the tenant's pinned drafts, newest first, one page at a // time: the generated query, finished by a terminal that takes the Grant. func PinnedDrafts(ctx context.Context, db *database.DB, g auth.Grant) (models.NoteCollection, error) { if err := g.Check(policies.NoteList); err != nil { return nil, err } return models.Notes(db).Where("pinned", true).WhereNull("published_at"). Latest().Limit(25).Get(ctx, g) } // PublishAt applies the entity's transition and saves the row with the Grant // the policy issued for it. func PublishAt(ctx context.Context, g auth.Grant, note *models.Note, at time.Time) error { if err := note.Publish(at); err != nil { return err } _, err := note.Save(ctx, g) return err } ``` ## Do not - Query without a Grant, or use the model core -- `model.NewTable`, a `*model.Builder`, what `Base()` returns -- outside `app/Models`. It answers untyped rows and is a second way to reach the table; `aru doctor` reports it as `model-core-outside-models`. - Read the clock, the database or the network in a rule of the entity: `aru doctor` reports it as `model-rule-touches-io`. - Assign a whole struct over a row from a query. A struct literal has no connection, and its `Save` returns `model.ErrUnwired`; set the fields. - Edit `Query.go` or a factory outside its custom block: the next `aru model:build` writes over it, and `aru doctor` reports a stale one as `model-query-stale`. - Open a subpackage under `app/Services`, or grow a service past what one aggregate needs: `service-subpackage` and `service-file-too-large`. ## Extending it Between `// arandu:begin custom` and `// arandu:end custom`: table settings inside the `TableSpec` (`Hidden`, `PerPage`, `SoftDeletes`, `Scopes`, `Events`), rules, scopes and relations at the end of the entity's file, named states in a factory, use cases beyond CRUD at the end of a service. `--force` rewrites everything outside the blocks; the example's files were finished by hand outside them and are changed by hand. ## Wiring `aru make:module` prints three lines and the claim: ```go // routes/web.go, the field on Deps Note *controllers.NoteController // bootstrap/app.go, in the routes.Deps literal Note: controllers.NewNoteController(services.NewNoteService(db)), ``` The route is in `arandu-http`. A migration needs nothing: the blank import of `database/migrations` in `bootstrap/app.go` links every one. A seeder goes in the registry of `database/seeders/seeders.go`. ## Acceptance test - `tests/Feature/TenantScope_test.go`, which `--tenant` generates, and the claim of the table in `tests/Feature/TenantScope_test.go`, written after reading every query of it. - A cross-tenant read and write through the HTTP path that answers 404, in the shape of `TestANoteOfAnotherTenantIsNotFound`. - The entity's rules tested alone, with no database and the time passed in, as `TestANoteIsPublishedOnceAtTheTimeItIsGiven` does. ## Limits The generator knows columns, not meaning: it writes no author, no ownership rule and no transition. A table with no tenant column -- a pivot keyed by two ids -- is invisible to the tenant test, and its isolation rests on the Grant of every query that reads it. The query builder covers what one aggregate needs; a report or a join belongs in a repository (`app/Repositories`) with its own policy, never in a second query path. ## Gates Run them all, in this order, as `AGENTS.md` lists them: ```sh export GOWORK=off aru model:build --check aru view:build gofmt -l $(find . -name '*.go' -not -path '*/testdata/*' -not -name '*.kyse.go') go vet ./... bash tests/test-layout-guard.sh go test -race ./... go build ./... aru doctor ```