This page gives an aggregate restore after delete, a move to another group, and a place in the group's file and the person's data export. Each section copies the notes files that do the same.
You need:
- The aggregate built (the aggregate reference).
A delete keeps the row and sets deleted_at, so it can come back. Copy the notes files that
mention restore.
- Domain. Add an empty restore request schema: nothing but a restore changes a deleted row, so
it needs no version. Add 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 and clearsdeleted_atonly where it is set; a live or missing row isNOT_FOUND. It raises the version, counts the plan's cap with the restored row, writes the audit row and records one change.listtakesdeletedand pages the deleted rows by the same cursor. - Handler and gate. A restore needs edit rights, like a delete.
needsRoomreserves room for one row, because a restore adds a live row as a create does. - Transports. Add
POST /:id/restorewith an 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>. It hard-deletes rows deleted longer ago than the restore window, and takesnowas a parameter so a test sets the clock. Notes keep 30 days (NOTE_RESTORE_DAYS). - Screens and SPA. A delete from a row's menu offers Undo for eight seconds instead of asking
(
useUndoToast). The list's menu has "Show deleted notes", with Restore for editors. The store never queues a restore: it needs the server. - Tests. Both transports (every role, a stranger, a live row, the cap), Postgres (version,
sequence, audit row, the deleted list's cursor), the purge with a fixed clock, the store, the
screens and an e2e spec. Notes:
e2e/notes-restore.e2e.tsandtests/integration/notes-purge.integration.test.ts.
Anything that hangs off the row, such as attachments, must go with it in the purge, or it is left behind.
A note can move to another group (NoteMoveCommand). Copy the notes files that mention move.
Domain. Add a request schema (
toGroupIdand 1 toNOTE_MOVE_MAXids) and a socket payload that adds the sourcegroupId.parseNoteMoveRequestrefuses repeats, and a move into the same group withSAME_GROUP. Add the command and the<kind>.moved_outand<kind>.moved_inevents.Repository. One transaction:
- Lock both groups in id order, so two opposite moves wait instead of deadlocking.
- Check the actor's role on both locked membership rows.
- Update
group_idand raise the version. Any id not in the source rolls everything back withNOT_FOUND. - Count the cap on the target.
- Record one change and one
audit_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 (
needsRoomwithtoGroupId), but only for someone who can write in the source. So a viewer of the source gets 403, not 402.Transports.
POST /move, registered beforePOST /, and the socket command<kind>.move.Screens. The list gets a tick box per row and one move form posting
{ toGroupId, noteIds }. The editor gets a form posting{ toGroupId }. Targets are the groups where the person may write (moveTargetsOf).SPA. The store sends the command and never queues it. A queued write for a moved item becomes a conflict (offline-writes.md).
Tests. Both transports (viewer, stranger, same group, all or nothing, cap), Postgres, the hint to members of both groups, the screens and an e2e spec. Notes:
tests/integration/note-move-push.integration.test.tsande2e/notes-move.e2e.ts.
Rows that point at others. A note points at nothing in its group, so it moves alone. An aggregate whose rows point at other rows of the same group decides first. Either the rows it points at move with it, in the same transaction, or the move is refused with a clear error. Never leave a reference in the old group.
A person can move everything a group holds to another group, usually before deleting the empty
one (group.moveAll). One transaction moves every registered aggregate. An aggregate that does not
register stays behind and is lost with the group.
- Write a
MovableAggregate(libs/server/groups/movable.ts): akindandmoveAll(sql, context). It moves the live rows inside the mover's transaction and returns how many moved. Stamp the rows withcontext.changeSequence. - Reuse the repository's own move; never copy its SQL.
libs/server/notes/note-movable.tscallsmoveNoteRows, asPostgresNoteRepository.movedoes. - Add it to
MOVABLE_AGGREGATESinapps/api/services/db-base.ts. The list's order is the order of the moves. - Add a case to
tests/integration/group-move-all.integration.test.ts: the rows moved, and a failure after yours leaves nothing moved.
The mover has already locked both groups, checked edit rights in both and recorded both changes.
Only the notes cap is wired: the gate hands one number, allowance, to the handler
(apps/api/cqrs/entitlement-needs.ts). A second capped aggregate needs the gate to read several
caps first; that is not built.
The owner of a group downloads it as one JSON file, and anyone who may create a group makes one
from such a file. The file holds one list per registered aggregate, keyed by its kind. An
aggregate that does not register is left out of every export.
- Domain. Write the entry's arktype schema and a parser that reuses the aggregate's field
rules (
noteExportEntrySchemaandparseNoteExportEntry). An entry has anid, what a person wrote, and the ids it points at. Never a person, a secret, a version or a deleted row. Refuse unknown fields ("+": "reject"). - Server. Write a
PortableAggregate(libs/server/groups/portable.ts) beside the repository, aslibs/server/notes/note-portable.tsdoes. Its parts are in the table below. - Add it to
PORTABLE_AGGREGATESinapps/api/services/db-base.ts. The order is the order of the writes, so tags come before the notes that point at them. - A change an older file cannot satisfy raises
GROUP_EXPORT_VERSION(libs/domain/groups/export-import.ts) and still reads the older version. - Add it to
tests/integration/group-files.integration.test.ts: a round trip gives the same contents under new ids, and a failure halfway writes nothing.
| Part | What it does |
|---|---|
kind |
The key in the file. Never rename it: files people keep name it |
one, other |
The nouns the import page counts it with |
limit |
The plan cap it counts against, if any |
parse, check |
One entry, and what is wrong across entries, such as a duplicate name |
read(sql, groupId) |
The group's live rows, in pages |
write(tx, entries, context) |
Writes the rows with new ids, and returns the new id of each file id |
write gives every row a new uuidV7() id and stamps it with context.changeSequence. It maps
what a row points at with context.newIdOf(kind, fileId).
Every row a person made, or that is about them, goes into their data export (data export).
- Add a section to
DATA_EXPORT_SECTIONSinlibs/server/jobs/data-export-sections.ts. Filter by the person, and leave out other people's data and secrets. - Add its rows to
tests/integration/data-export.integration.test.ts.