An aggregate is one kind of thing a product keeps, such as a note, an invoice or a task. This page lists every file a new aggregate needs, in the order to write them, using notes as the worked example. Copy the notes files, rename them, and change the fields and rules; do not invent a second shape.
Notes are the reference because they touch every layer and hold no product rules: a note belongs to one group, has a title, a body, who created and last changed it, and a version. Every member of the group reads notes; an editor or above writes them.
- It lives inside a group. Groups are the only tenancy boundary, so every row carries a
group_idand every check starts from the actor's membership of that group. - Authorization is in the handlers. The REST route and the socket only parse and dispatch. The handler reads the actor's role and refuses before it touches anything, so both transports get the same answer from the same code (ADR 002, "Authentication, authorization, revocation").
- Every write moves the group's change sequence and writes an outbox row, in the same transaction as the write. The worker turns the outbox row into a hint on every member's socket, and their pages read again. Nothing else is needed for live updates.
- Every command carries an idempotency key over the socket, and every update and delete names the version it saw. A stale version is refused with a typed conflict that carries the current version, instead of overwriting a change the client never saw.
- Screens are dumb: props in, callbacks out (see "Three layers of UI" in
AGENTS.md). - A feature that stores something new about a person edits the privacy page
(
apps/mpa/routes/[[lang]]/privacy.tsx) in the same pull request: what is kept, for how long, and who can read it.
| File | What it holds |
|---|---|
+lib.ts |
The Note shape; the arktype request schemas (the REST bodies, which are also the form field names, and the socket payloads); NoteError and NoteVersionConflictError; the commands (NoteCreateCommand, NoteUpdateCommand, NoteDeleteCommand, NoteMoveCommand) and queries (NoteListQuery, NoteGetQuery); the outbox event kinds; the authorization rule; the NoteRepository port. |
+lib.test.ts |
The schemas and the authorization rule. |
deno.json |
Makes it a workspace member. |
Then register it in the root deno.jsonc: the workspace array (or its tests never run,
docs/handoff.md trap 1) and an @domain/notes import alias. Add its deno.json to the COPY
lines of Dockerfile.base; tests/dockerfile-base.test.ts fails until you do.
The authorization rule is two functions, assertCanReadNotes(role) and
assertCanWriteNotes(role). A person who is not a member gets GROUP_NOT_FOUND, never "forbidden",
so a stranger cannot learn that a group exists.
| File | What it holds |
|---|---|
migrations/2026_10_02_0001_notes.sql |
The notes table: group_id, the fields, version, change_sequence (the group sequence the note was last written at), who created and updated it, and deleted_at (a delete keeps the row). |
schema.sql |
The same table appended, so the snapshot matches the migrations. |
tests/integration/schema-snapshot.integration.test.ts applies every migration in the folder and
compares every table with schema.sql, so a migration missing from schema.sql fails it. Compose
applies migrations on every start; nothing else is needed to deploy one.
Naming a migration. A file is named YYYY_MM_DD_NNNN_words_in_snake_case.sql (ending in
.no_transaction.sql for a statement that cannot run in a transaction), and a new one must sort
after every migration already on main. The runner applies files in name order and
skips the ones a database has recorded, so a file that sorts earlier runs after them on your
database and before them on an empty one, where the tables it changes do not exist yet. The date is
a counter, not the calendar, and the names already run ahead of it: do not use today's date. Take
the newest name on main and go one step past it, either the next day with 0001 or the same day
with the next number. Then append the file name as the last line of ON_MAIN in
tests/migration-names.test.ts, which fails for a file that is not listed or sorts before the
newest one listed. When another pull request merges a migration first, rebase, rename yours past
it and append it after theirs. A migration on main is never renamed: databases that applied it
know it by its name.
| File | What it holds |
|---|---|
postgres-note-repository.ts |
Reads and writes. Each write runs in a transaction that calls recordGroupChange (libs/server/groups/group-change-log.ts), which moves the group's sequence and writes the outbox row. It checks no role. |
note-list-cursor.ts |
The signed paging cursor, bound to the user and the group, so a cursor cannot be replayed by someone else or on another group. |
deno.json |
Workspace member; register it and add an @server/notes/ alias like the domain. |
The version check is the WHERE version = ${expected} of the UPDATE. When no row matches, the
repository reads the note once more to tell "gone" (NOTE_NOT_FOUND) from "moved on"
(NoteVersionConflictError with the current version). A create retried with the same id and the
same content returns the first note instead of failing.
Expose it on AppDbBase in apps/api/services/db-base.ts as a getter built per access, like
group (trap 5: a cached repository escapes the transaction).
handlers.ts holds one factory per command and query. Each reads the actor's role in the group,
runs the authorization rule, then calls the repository. The session gate on both buses has already
checked the session's strength.
apps/api/cqrs/note-dependencies.ts wires the handlers to db.note and to the membership read
(db.group.getForMember, always from Postgres). One file per handler under
cqrs/command-handlers/ and cqrs/query-handlers/ builds it, and cqrs/+init.ts registers it on
the bus. The idempotency middleware on the command bus needs nothing from you: a command whose data
carries an idempotencyKey runs once per user and key.
| File | What it holds |
|---|---|
apps/api/features/notes/list.ts |
One page of the list, with its cursor. Both transports call it, so they cannot drift apart. |
apps/api/features/notes/operations.ts |
The operations note.create, note.update, note.delete, note.restore, note.move (commands) and note.list, note.get, note.locate (queries): parse the payload, dispatch. note.locate finds the group of a note by its id alone, for a link to a note of another of the person's groups: it answers only a member, and NOTE_NOT_FOUND otherwise. |
apps/api/features/notes/errors.ts |
REST error codes and statuses. A version conflict answers 409 with details.currentVersion (docs/api-errors.md). |
apps/api/routes/notes.ts |
GET, POST, PATCH, DELETE and POST /:noteId/restore under /api/groups/:groupId/notes, with the same-origin guard on writes and an optional Idempotency-Key header. |
apps/api/services/note-list-cursor.ts |
The cursor codec, keyed from the cookie secret. |
An aggregate lists its operations once, in operations.ts. Both transports serve that one list
through one dispatcher: the socket (/api/ws) and the call route, POST /api/call/<name>
(apps/api/routes/call.ts), which takes the payload as the JSON body and a command's key in the
Idempotency-Key header. Both refuse a call whose session is not the user the page names. A new
aggregate therefore writes no per-command REST route: it needs only its list (GET) route. Every
route under /api stands behind one check (createApiApp in apps/api/app.ts) that refuses a
request whose page names another user, and the SPA names its user on every request: its one REST
function, apiFetch in apps/spa/src/state/api.ts, sends each one through apiAsUser
(tests/spa-reads.test.ts fails when a store calls another way). The per-command routes of notes
and groups in the table above are older than the call route and are still used by the e2e specs to
seed data; do not copy them.
Then wire them: mount the route in apps/api/index.ts (before /groups), add the operations
to the Realtime in apps/api/services/realtimeHub.ts, and map NoteError in toRequestError
(apps/api/services/realtime.ts), so the client gets forbidden, not_found or conflict with
the domain code (and the current version) in details.
The worker needs no change. A note's outbox row names the group as its aggregate and the group's sequence as its version, so it is announced exactly like a group change.
An aggregate has two pages, a list page and an editor page, and each is one screen. Copy
both. The list page only reads and links; everything that writes happens on the editor page, which
has its own address, a back link to the list and room for a long text. The editor is one component
for both modes: /<entity>/new shows it with empty fields and "Create" (notes say "Add note",
the verb every other note screen uses), /<entity>/:id with the saved fields, and its Save and Discard only while a field differs from what is saved. Groups are
the second example: groups-screen.tsx lists them, each row one link to its editor, and
group-editor-screen.tsx serves /groups/new and /groups/:id, with the members, the plan and
the activity log on tabs of their own (/groups/:id/members, /billing, /activity). An entity that is created and never edited, such as an API token or an invitation,
gets a create-only page instead (api-token-new-screen.tsx at /tokens/new,
invitation-new-screen.tsx at /groups/:groupId/invitations/new): the form, then, once it exists,
its secret or link shown once on the same page with a copy control and Done. Leaving before Done
asks first, and the secret lives only in a store the page owns, never in the address, the history or
the device cache.
| File | What it holds |
|---|---|
progressive.tsx |
NOTE_PATHS: the pages the screens link to: /notes, /notes/new and /notes/:id. They carry no group id: the SPA acts on the person's selected group (see "How a group call travels", item 8, in docs/handoff.md). |
notes-screen.tsx |
The list page, NotesScreen: props in, links and callbacks out. "New note" and each note's title are links; the only form is the move of selected notes. A viewer gets the list and no "New note". |
note-editor-screen.tsx |
The editor page, NoteEditorScreen, for /notes/new (no note yet) and /notes/:id. Its fields carry the API's field names; "Save" and "Cancel" are the form's own, and the app's callbacks take the submit. A viewer gets the note as text. A note that cannot be read is "This note does not exist." The version conflict link and message are part of the screen. |
screens.test.tsx |
Renders the newsletter screens on the server and checks that every action is a working form or link whose fields match the request schema. |
interactions.test.tsx |
Drives each screen in a browser DOM: typing, submitting through the callback, and the delete dialog. |
Deleting asks first. With the app's onDelete, "Delete" in the page's menu opens
preact-components' ConfirmDialog; without it, the menu has no delete.
A note in another of the person's groups. A note belongs to one group, and the pages work on the
person's selected group. A link must never change the selection (another site could switch a
person's group with one), so opening /notes/:id never switches. When the note is in another of
the person's groups, the page (found through note.locate) names that group and offers "Switch to
<group>": only that button selects the group, through the same group.select command as the picker,
and the note then opens. A note that does not exist and a note in a group the person is not in both
show the plain "Note not found" page; the lookup answers them identically, so it never says whether
an id is taken.
| File | What it holds |
|---|---|
state/notes.ts |
The store: reads a group's notes with apiAsUser, writes with callCommand from modules.ts (which adds the idempotency key and picks the socket or POST /api/call/<name>), and turns a version conflict into the conflict UI. |
state/notes.test.ts |
The store against fake calls. |
views/NotesView.tsx |
Passes the store and the group's role to NotesScreen. |
views/NoteEditorView.tsx |
Passes the store and the group's role to NoteEditorScreen for /notes/new and /notes/:id, with the offline conflict state above it. |
views/spa-paths.ts |
The route table: every route's pattern, and open: true on a page a signed-out person may see. Add the aggregate's routes here; each row must say open, and a page that is not open is for signed-in people. The sign-in gate (views/sign-in-gate.ts) reads the table to send a signed-out visit to sign-in and back, and preact-components' UnsavedGuard in NoteEditorView.tsx reads it to know a link stays in the app, so it asks before the person leaves the editor with text that is not saved. |
app.tsx |
What each route of the table draws (PAGES; the type check fails for a route in the table with no page, and for a page with no route), and the pull: a hint for the open group reads its notes again. |
modules.ts |
The composition root: the only file that imports the WebSocket module and the offline layer. Stores import their ports from it (offline.md, "The modules and their switches"). |
offline/notes-collection.ts |
Notes are offline-writable (level 3): their adapter for the shared collections layer. A level 1 or 2 aggregate has no file here. |
The store does not move the group's cursor on its own writes: another member's change may have taken the sequence just before, and moving past it would drop that change's hint. Its own hint costs one extra read instead.
Choosing the offline level
Decide the level before writing the store (ADR 003 (on GitHub), section 4; offline.md lists where every list stands today).
Level 1, online only. Write the store as above and nothing else. With no network its read fails and the screen shows the store's error.
Level 2, offline-readable. The default for a list. Four steps, as in state/members.ts:
- Add the list's key to
READ_KEYSinstate/read-keys.ts. A key names everything the answer depends on: the group, and the cursor when the list is paged. - Give the store's factory a
device: LocalReads = noLocalReadsparameter, and passlocalReads(frommodules.ts) where the page's store is built. - Make the read
device.through(key, read, early).readmust throw for a refusal (wrap a result object's failure inReadRefused), so only an unreachable server is answered by the copy.earlypaints the copy when the screen has nothing to show, returnsfalsewhen it painted nothing, and takes the copy back when it is called withundefined. - Add the list to
CASESinstate/device-copies.test.ts. That one entry runs the five promises of a level 2 list against the store: the copy answers an unreachable server, every answer replaces it, a refusal is not hidden by it, nothing is kept while the module is off, and it shows before the server has answered.
A list that can mislead when old (it lists access a person may have revoked) also exposes
savedAt from the answer (fresh ? null : savedAt), and its screen says the list is a saved copy
with SavedCopyNotice from @spy4x/preact-system, as libs/ui/api-tokens.tsx does. The notice is
rendered always, with savedAt null while the list is fresh, and its title names the list, so
two notices on one page sound different to a screen reader.
Level 3, offline-writable. Only for an aggregate a person must be able to change with no
network; it costs a conflict screen. Copy offline/notes-collection.ts: one defineCollection
with the aggregate's commands, its error codes (classifyByCode), the wording of its failures and
its device copy, added to the collections list in offline/index.ts. The queue, the keys, the
lock and the conflict rules come from @spy4x/realtime/collections and are not written again.
Its device copy is a view over the changes copy (offline/device-copy.ts, below), whose clear
finishes the database, so a write still on its way when the person signs out saves nothing and
cannot put the data back. Then wrap the store's dependencies as offlineNotes does, and copy
offline/notes-collection.test.ts.
Taking part in the changes pull
A level 3 aggregate of a group is read by the changes pull (offline.md, "Reads: the device first"), so a device asks only for what changed. Notes and tags take part; copy them:
- Server. A
ChangesAggregatebeside the repository (libs/server/notes/note-changes.ts): itskind, asequencesquery of the live rows'change_sequenceafter the cursor, and arowsquery (listChangedin the repository) of the rows in(after, through], in the list's shape. Add it toCHANGES_AGGREGATESinapps/api/services/db-base.ts. - Tombstones. Every write that makes a row leave the group (a delete, a move away) calls
recordTombstonesfromlibs/server/groups/group-changes.tsin its own transaction, with the sequence it took. A write that moves every row at once raises the group's oldest cursor withforgetChangesBeforeinstead. A soft-deleted row is a tombstone, never a row, so its content never reaches a device. - SPA. Add the kind to
ChangesRowsandCHANGES_KINDSinoffline/device-copy.ts, with an arktype schema of its row, and a view over the copy asnotesandtagsare there. The collection's list read callspullOrKeepand then reads the view. - Tests. A case in
tests/integration/group-changes.integration.test.tsfor its upserts and tombstones, and one inoffline/device-copies.test.tsfor the view.
| File | What it proves |
|---|---|
apps/api/features/notes/transports.test.ts |
Every scenario through the socket and through the call route, on real buses and handlers, and the REST list reads: a viewer's writes are refused; a stale version conflicts. |
apps/api/features/notes/operations.test.ts, routes/notes.test.ts, routes/call.test.ts |
Parsing and dispatch of each transport. |
tests/integration/notes.integration.test.ts |
Postgres: one sequence step and one outbox row per write, conflicts, viewers, paging. |
e2e/notes.e2e.ts |
Two members: one creates, edits and deletes on the note pages; the other's open tab follows without a reload; a viewer sees a note without edit controls. |
e2e/notes-restore.e2e.ts |
Delete then Undo, delete then Show deleted notes and Restore, with the other member's open tab following. |
tests/integration/notes-purge.integration.test.ts |
The worker's purge removes notes deleted over 30 days ago and keeps restored ones, with the clock set by the test. |
e2e/notes-move.e2e.ts |
Moving one note and ticked notes, with members of both groups watching. |
tests/integration/note-move-push.integration.test.ts |
Members of both groups get a hint for a move; a stranger gets none. |
Members join a group through an invitation (docs/handoff.md, "Members, invitations and
ownership"). A test that needs a second member seeds it instead of walking the invitation flow:
the integration test inserts the row, and the e2e spec calls POST /api/test/add-member
(apps/api/routes/dev.ts, mounted only in development). Teach POST /api/test/cleanup-user to
delete the new table's rows too.
Give the aggregate its operator view as an admin module: one folder in apps/admin/src/modules/ and
one file in apps/api/routes/admin/, as admin-modules.md describes.
Tags are the worked example: a note names tags by id (tagIds), and a tag knows nothing of notes.
Copy these choices:
- The link is its own table (
note_tags, with the group's change log recording every change), and it has no foreign key to the child on the parent's side. Deleting the parent removes its links and never touches the child's row or version, so a delete of a tag moves no note. - The child's write carries the ids; the server ignores unknown ones and answers with the links the child really has. A client never needs to know whether the parent still exists.
- Moving the child maps the parent by name: a moved note keeps its tags by name, and the target group gets any tag it lacks, each with its own audit event.
- The parent is capped per group (
TAG_MAX_PER_GROUP), because its list has no pages. The repository counts after its insert, inside the transaction that holds the group's row, so two creates at once cannot both pass. A move whose new tags do not fit is refused whole (TAG_LIMIT_REACHED): moving the notes without their tags would lose them silently. - Offline, the parent's collection is declared first in
offline/index.tsand named in the child'sdependencies(parents,on,without). The four rules are in offline.md. - The child's screens read the parent's store (
state/tags.ts) for their picker, labels and filter; the filter lives in the address (useUrlFilters) and runs over the local list.
The tag files, in the order of this document:
| Step | Files |
|---|---|
| 1. Domain | libs/domain/tags/+lib.ts, +lib.test.ts, deno.json |
| 2. Database | libs/server/db/migrations/2026_10_27_0001_tags.sql, schema.sql |
| 3. Repository | libs/server/tags/postgres-tag-repository.ts, and setNoteTags and the move mapping in libs/server/notes/postgres-note-repository.ts |
| 4. Handlers | apps/api/features/tags/handlers.ts, apps/api/cqrs/ (dependencies, command and query handlers, +init.ts) |
| 5. Transports | apps/api/features/tags/operations.ts, errors.ts, apps/api/routes/tags.ts, realtimeHub.ts |
| 6. Screens | libs/ui/tags-screen.tsx, tag-parts.tsx, the tag parts of notes-screen.tsx and note-editor-screen.tsx, tags.test.tsx |
| 7. SPA wiring | state/tags.ts, offline/tags-collection.ts, tags-cache.ts, views/TagsView.tsx, the tag parts of NotesView.tsx, NoteEditorView.tsx, pull.ts, OfflineStatus.tsx |
| 8. Tests | apps/api/features/tags/transports.test.ts, tests/integration/tags.integration.test.ts, state/tags.test.ts, offline/tags-collection.test.ts, e2e/tags.e2e.ts, e2e/tags-realtime-off.e2e.ts |
The MPA has no session, so tags exist in the SPA only.
A delete keeps the row (deleted_at), so it can come back. An aggregate follows the notes files
that mention restore:
- Domain. A request schema (empty: a restore needs no version, because nothing but a restore
changes a deleted row), a socket payload with the group and the id, a command, a
<kind>.restoredevent, an audit and activity kind, and adeletedflag on the list query. - Repository.
restoreruns in one transaction: it checks the role on the locked membership row, clearsdeleted_atonly where it is set (a live or missing row isNOT_FOUND), raises the version, counts the plan's cap including the restored row and refuses with the plan's refusal, writes the audit row and records one change in the group's log.listtakesdeletedand pages the deleted rows by the same cursor, served by a partial index. - Handler and gate. Edit rights are required, as for a delete.
needsRoomreserves room for one row, because a restore adds a live row exactly as a create does. - Transports.
POST /:id/restore(empty body),?deleted=trueon the list, the socket command<kind>.restoreand thedeletedflag of<kind>.list. - Purge. The worker's nightly cleanup (
libs/server/jobs/cleanup.ts) calls apurgeDeleted<Kind>that hard-deletes rows deleted more than the restore window ago. It takesnowas a parameter, so a test sets the clock. Notes use 30 days (NOTE_RESTORE_DAYS). Anything that hangs off the row, such as attachments, must be removed with it, or the purge leaves orphans. - Screens and SPA. A delete from a row's menu needs no confirmation: it offers Undo for eight
seconds instead (
useUndoToast, which pushes it to the app's toast list), and the list's menu has "Show deleted notes", which lists the deleted rows with a Restore button for editors. The store never queues a restore: it needs the server, like a move. - Tests. Both transports (every role, stranger, a live row, the cap), Postgres (version, sequence, audit row, the deleted list's cursor), the purge against a fixed clock and through the nightly job, the store, the screens and an e2e spec.
A note can move to another group (NoteMoveCommand). An aggregate becomes movable by following the
same steps; copy the notes files that mention move:
- Domain. A request schema (
toGroupIdand 1 toNOTE_MOVE_MAXids), a socket payload schema that addsgroupId(the source),parseNoteMoveRequest(refuses repeats and a move into the same group withSAME_GROUP), the command, and<kind>.moved_outand<kind>.moved_inevents. - Repository. One transaction that locks both groups in id order (so two opposite moves wait
for each other instead of deadlocking), checks the actor's role on both locked membership rows,
updates
group_id, raises the version, refuses withNOT_FOUNDand rolls back when any id is not in the source, counts the cap on the target, records one change per group (moved_outon the source,moved_inon the target) and writes oneaudit_eventsrow per group. The id and the history stay: a move is an update, not a copy and a delete. - Handler and gate. The handler asks for edit rights in the source, then in the target. The
entitlement gate judges the target group (
needsRoomwithtoGroupId), so a move into a full group is refused, but only for someone who can write in the source too (itsjudgesreads the source role throughroleIn), so a viewer of the source gets 403, not 402; the repository counts all moved rows, the gate only reserves room for one. - Transports.
POST /move(registered beforePOST /) and the socket command<kind>.move. - Screens. The list gets a tick box per row and one form posting
{ toGroupId, noteIds }; the item page gets a form posting{ toGroupId }. Targets are the person's groups where they may write (moveTargetsOf). - SPA and offline. The store sends the command over the socket and never queues it. A queued
write for a moved item becomes a conflict (
docs/offline.md). - Tests. Both transports (viewer, stranger, same group, all-or-nothing, cap), Postgres (one
sequence step and one audit row per group, rights in both groups, opposite moves), the hint for
members of both groups (
tests/integration/note-move-push.integration.test.ts), the screens and an e2e spec. - Entities tied to their group. Notes reference nothing, so a note moves alone. An aggregate whose rows point at other rows of the same group (in Financy, a transaction's account and category) must decide before it becomes movable: the references move with it in the same transaction, or the move is refused with a clear error when a reference would be left behind. Never move the row and leave its references in the old group.
A person can move everything a group holds to another group (GroupMoveAllCommand,
POST /api/groups/:groupId/move-all, socket group.moveAll), usually before deleting the emptied
group. One command moves every registered aggregate in one transaction, so a movable aggregate must
register, or its rows stay behind and are lost when the group is deleted.
- Write a
MovableAggregate(libs/server/groups/movable.ts): akindandmoveAll(sql, context), which moves the aggregate's live rows inside the transaction the mover opened and returns how many moved. Reuse the repository's own move (the notes filelibs/server/notes/note-movable.tscallsmoveNoteRows, the functionPostgresNoteRepository.moveuses); never copy its SQL. Stamp the rows withcontext.changeSequence, the target's sequence. - Add it to
MOVABLE_AGGREGATESinapps/api/services/db-base.ts. The order is the order of the moves; the mover has already recorded both groups' changes, locked both groups and checked edit rights in both. - Only the notes cap is wired today. The entitlement gate reads one cap for one command and hands
it over as one number,
allowance(apps/api/cqrs/entitlement-needs.ts), so the handler passes{ maxNotes: allowance }. A second capped aggregate needs the gate to read several caps for one command first; that is not built. Count the rows against the target under the lock, asmoveNoteRowsdoes withassertRoomFor. - Add a case to
tests/integration/group-move-all.integration.test.ts: the rows moved, and a failure after your aggregate leaves nothing moved.
The counts go into the audit events, which the activity page words as "N items".
The owner of a group downloads it as one JSON file (GET /api/groups/:groupId/export), and anyone
who may create a group makes a new group from such a file (POST /api/groups/import/check, then
POST /api/groups/import). The file holds the group's settings and one list per registered
aggregate under aggregates, keyed by the aggregate's kind. An aggregate that does not register
is left out of every export, so a person who leaves with their file loses it.
- In the domain, write the entry's schema with arktype and a parser that reuses the aggregate's
own field rules (
noteExportEntrySchemaandparseNoteExportEntryinlibs/domain/notes/). An entry has anid, the fields a person wrote, and the ids of what it points at. Never a person, a secret, a version or a deleted row. Refuse unknown fields ("+": "reject"): the file's version says what it may hold. - Write a
PortableAggregate(libs/server/groups/portable.ts) next to the repository, aslibs/server/notes/note-portable.tsdoes:kind, and the nounsoneandotherthe import page counts it with. Never renamekind: files people keep name it.limit, the plan cap its count goes under, if one does. The importer checks a new group's plan against the file's count.parse, one entry;check, what is wrong across entries (a duplicate name).read(sql, groupId), the group's live rows in pages, so a large group is never held whole.write(tx, entries, context), inside the import's transaction: every row gets a new id fromuuidV7(), is stamped withcontext.changeSequence, and maps what it points at withcontext.newIdOf(kind, fileId). It returns the new id of each entry by its id in the file.
- Add it to
PORTABLE_AGGREGATESinapps/api/services/db-base.ts. The order is the order of the writes, so an aggregate others point at (tags) comes before them (notes). - A change to an entry's shape that an older file cannot satisfy raises
GROUP_EXPORT_VERSION(libs/domain/groups/export-import.ts) and keeps reading the older version. A file from a newer version is refused. - Add the aggregate to
tests/integration/group-files.integration.test.ts: the round trip gives the same contents under new ids, and a failure halfway writes nothing.
Every row a person made or that is about them goes into their data export
(data-export.md): add a section to DATA_EXPORT_SECTIONS in
libs/server/jobs/data-export-sections.ts, filtered by the person, without other people's data
or secrets, and add its rows to tests/integration/data-export.integration.test.ts.
A setting is a property of the group itself, such as its description, colour and emoji. It is not
a new aggregate: the group already has the table, the repository, the transports and the change
event. Follow the details command (group.updateDetails, spy4x/template#134) file by file.
- Migration. One file in
libs/server/db/migrations/, named by the rule in "Naming a migration", adds the column, with a default or a nullable type so existing rows stay valid, and aCHECKfor a limit the database can hold. Mirror the same lines inlibs/server/db/schema.sql;schema-snapshot.integration.test.tscompares it with the migrations. Tests that build their schema from the migrations read the folder, so they need no edit. - Domain. Add the field to
GroupandGroupSummaryinlibs/domain/groups/+lib.ts, one parser per field that throwsGroupErrorwithINVALID_REQUEST, and a limit constant the screen can import. A colour is a lower-case#rrggbb, checked withisHexColor(@spy4x/platform/universal/color) and drawn withreadableTextColortext on it. Settings that change together share one command so one request is one audit row. - Repository. Add the column to the selects and to
toGroupandtoSummary. The write locks the member's row, checks the role, updates, then callsaudit()andrecordChange()in the same transaction. That outbox row is the change event. - Command and handler. The handler judges the role again (
assertCanEditDetailsreuses the rename rule), so REST and the socket enforce it alike. Register the command inapps/api/cqrs/+init.ts,apps/api/index.tsandapps/api/services/realtimeHub.ts. - Transports. A REST route (
PUT /api/groups/:groupId/details) and the socket command (group.updateDetails), with the same bad-input and role tests as the other group commands. - Screen. Add the field to the group editor (
libs/ui/group-editor-screen.tsx), which serves both/groups/newand/groups/:groupId, and to the create request, so a new group is created with it in one request. A role that may not edit sees the value as text. New fields onGroupRow,PickerGroupand the SPA'sGroupItemare optional, because the offline store may hold groups saved before the field existed. - Other members. Nothing to add:
recordChange()makes the worker send a hint to every member's socket, and their pages read the group again. Test it with two pages and a count of page loads, ase2e/groups-details.e2e.tsdoes.
The group overview (/overview/:groupId, spy4x/seed#219) is a page of widgets: numbers and charts
about the group over a range of days. Each aggregate offers its own; the notes' are the reference.
A widget shows counts only, never an item's content or who did it.
- Definition. Add the widget to the aggregate's list in its domain package, as
NOTE_WIDGETSinlibs/domain/notes/overview.tsdoes: a stableid(a saved layout keeps it, so it never changes), atitleKey(theappcatalog key of its name, translated asi18n/TRANSLATING.mdsays), asize(statfor a number,fullfor a chart),canView(role)and theoperationthat answers it. Answer a number withCountWidgetResultand a chart withSeriesWidgetResultfrom@domain/groups, both with the period before the range, ornullwhen the group did not exist for all of it. - Query and handler. One query class per widget, carrying the group, the range and the zone,
and a handler that makes the same membership check as the aggregate's list before it reads
anything (
apps/api/features/notes/widgets.ts): a stranger is told the group does not exist. The numbers come from a read-only repository that reads one group only (libs/server/notes/note-overview.ts), with days cut at midnight in the person's zone. Register the handler inapps/api/cqrs/+init.tsand the operation inapps/api/services/realtimeHub.ts. - Drawing. Add a
WidgetViewto the aggregate's drawings inlibs/ui(NOTE_WIDGET_VIEWSinlibs/ui/note-widgets.tsx): what a screen reader hears while it loads, the error, anddraw, which turns the data into the card's caption and content, or one empty line. A number takesWidgetStatand a neutralchangeTextline; a chart takesLineChartwith a one-sentence summary as its caption and the previous period dashed. - Registry. Add the list and the drawings to
OVERVIEW_WIDGETSandOVERVIEW_VIEWS(libs/ui/overview-widgets.ts). The SPA's overview and the admin group page both read it. The admin page's numbers come fromcreateAdminGroups().widget(apps/api/routes/admin/groups.ts), which knows only the readers it is given: add the aggregate's readers next toNOTE_WIDGET_READERSthere, or the admin card shows its error. A person's saved layout gets the new widget at the end, shown. - Tests. The handler's tests (
widgets.test.ts): a stranger getsGROUP_NOT_FOUNDand nothing is read, and only the named group is read. The repository against Postgres (tests/integration/note-overview.integration.test.ts), with another group's rows that must not count. The drawing inlibs/ui/overview.test.tsx.
A change that a person should hear about, and did not make themselves, writes a notification
(spy4x/template#147). Follow libs/server/groups/group-notifications.ts.
- Same transaction. Call
createNotification(tx, …)from@server/notificationsinside the transaction of the change, after the change, so the row exists exactly when the change does. The group writers call a helper fromgroup-notifications.tsafter theiraudit()line. - A kind, a payload, a link. Add the kind to
NotificationKindinlibs/domain/notifications. Its sentence goes innotificationMessagethere, with its key in everyi18n/app/*.ts; a kind missing there shows a generic line. The inbox and the Telegram message both say that sentence, in the person's language. The payload holds only the facts the sentence needs, never a note body or an address. The link is an in-app path; the database andcreateNotificationboth refuse anything else. - Nothing else. The row announces itself:
createNotificationsends the Postgres notificationuser_notificationin the same transaction, the API turns it into a hint on the person's sockets, and the page reads the new count. A test per writer intests/integration/notifications.integration.test.tsshows the row and who got it.
A new account should not open onto an empty app. Sign-up queues one worker job per account
(libs/server/jobs/starter-data.ts), and the job creates the template's starter data: one welcome
note in the person's first group. The sign-up never waits for it and never fails because of it.
- The request row is the marker. Sign-up writes one
starter_data_requestsrow and the outbox job in its own transaction, throughdb.starterData.queue(userId). The job's id is the row's id. The worker setsdone_atwhen it has finished, so a job that runs again does nothing. - Make the write repeatable. The welcome note's id is the request's id, so a retry that lands
between the note and the marker finds the note and adds none. Do the same for your data: derive
each row's id from the request id (or use
ON CONFLICT DO NOTHING), never "insert and hope". - Add yours inside
starterDataJob, after the welcome note, using your repository's own create (the one the handlers use), with the person as the actor. Skip quietly when the group is gone:NoteErrorthere means "nothing to seed", not a failure to retry. Financy's default categories would be one more call here. - Test it with a forced retry (
tests/integration/starter-data.integration.test.ts): run the job twice, and once more with the outbox row put back as unprocessed, and count the rows.
Every list also needs a first-run empty state: EmptyState with one action when there is
something to do (New note, New group), and none when there is not (the inbox, the activity
log). See docs/design/ui-layout.md.
The home page of a new account shows a first-steps checklist and offers a short tour
(OnboardingCard in libs/ui/onboarding-card.tsx, over preact-components' OnboardingChecklist
and Tour). Nothing ticks a step: each one is a fact your feature already stores, read again when
the home page opens and, while it is open, when a hint says the person changed something. Once the
person has dismissed the card the page stops asking.
- The fact. Add one
EXISTS (…)toreadOnboarding(libs/server/auth/onboarding.ts) and its name toOnboardingSteps(libs/domain/identity/+lib.ts). Count only what the person did themselves: the welcome note above is created in their name and is left out by its id. The read runs for every person on their home page, so give it an index. - The step. Add it to
onboardingStepsin the card, with the link to the page that does it. - The tests. One step in
tests/integration/onboarding.integration.test.ts(the fact, and that what another member did in the person's own group does not tick it) and the title inlibs/ui/onboarding.test.tsx.
Dismiss and the end of the tour are kept on the person's users row (onboarding_dismissed_at,
onboarding_tour_seen_at) through the onboarding.dismiss and onboarding.tour-seen commands, so
both follow the person to every device. Add a screen to the tour in tourSteps
(libs/ui/onboarding-card.tsx), with the CSS selector of the control that opens it and its title
and text keys in every i18n/app/*.ts.
A new aggregate adds fake rows to the demo in the same PR (demo.md (on GitHub), "When the database
changes"): a few believable items per themed group in apps/worker/demo/fake-data.ts, written in
apps/worker/demo/seed.ts through the aggregate's own repository, and the new counts in
tests/integration/demo-seed.integration.test.ts. The nightly wipe empties the new table without a
change.
- The SPA creates a note in the group it shows, over the socket, so a note never lands in a group other than the one on screen.
- Every write reads the actor's role again inside its own transaction with
lockActorRole(libs/server/groups/group-change-log.ts), as the notes repository does: it locks the group row, then the membershipFOR SHARE, so a write racing a removal or a demotion waits for it and is then refused (spy4x/template#230). - The SPA reads the whole list again on every hint. A pull of only the notes changed after the
cursor needs the
change_sequencecolumn, which is already there, and a query that uses it.