@arki/journal 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -2
  3. package/dist/api.d.ts +336 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +2 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/command.d.ts +27 -0
  8. package/dist/command.d.ts.map +1 -0
  9. package/dist/command.js +157 -0
  10. package/dist/command.js.map +1 -0
  11. package/dist/debug.d.ts +7 -0
  12. package/dist/debug.d.ts.map +1 -0
  13. package/dist/debug.js +8 -0
  14. package/dist/debug.js.map +1 -0
  15. package/dist/dot.d.ts +102 -0
  16. package/dist/dot.d.ts.map +1 -0
  17. package/dist/dot.js +159 -0
  18. package/dist/dot.js.map +1 -0
  19. package/dist/entity.d.ts +56 -0
  20. package/dist/entity.d.ts.map +1 -0
  21. package/dist/entity.js +174 -0
  22. package/dist/entity.js.map +1 -0
  23. package/dist/errors.d.ts +47 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +112 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/event.d.ts +9 -0
  28. package/dist/event.d.ts.map +1 -0
  29. package/dist/event.js +19 -0
  30. package/dist/event.js.map +1 -0
  31. package/dist/fold.d.ts +29 -0
  32. package/dist/fold.d.ts.map +1 -0
  33. package/dist/fold.js +49 -0
  34. package/dist/fold.js.map +1 -0
  35. package/dist/handlers.d.ts +26 -0
  36. package/dist/handlers.d.ts.map +1 -0
  37. package/dist/handlers.js +130 -0
  38. package/dist/handlers.js.map +1 -0
  39. package/dist/history.d.ts +49 -0
  40. package/dist/history.d.ts.map +1 -0
  41. package/dist/history.js +259 -0
  42. package/dist/history.js.map +1 -0
  43. package/dist/hooks.d.ts +14 -0
  44. package/dist/hooks.d.ts.map +1 -0
  45. package/dist/hooks.js +67 -0
  46. package/dist/hooks.js.map +1 -0
  47. package/dist/id.d.ts +18 -0
  48. package/dist/id.d.ts.map +1 -0
  49. package/dist/id.js +41 -0
  50. package/dist/id.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +5 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/journal.d.ts +51 -0
  56. package/dist/journal.d.ts.map +1 -0
  57. package/dist/journal.js +181 -0
  58. package/dist/journal.js.map +1 -0
  59. package/dist/listen.d.ts +37 -0
  60. package/dist/listen.d.ts.map +1 -0
  61. package/dist/listen.js +90 -0
  62. package/dist/listen.js.map +1 -0
  63. package/dist/orpc.d.ts +21 -0
  64. package/dist/orpc.d.ts.map +1 -0
  65. package/dist/orpc.js +57 -0
  66. package/dist/orpc.js.map +1 -0
  67. package/dist/pg.d.ts +537 -0
  68. package/dist/pg.d.ts.map +1 -0
  69. package/dist/pg.js +80 -0
  70. package/dist/pg.js.map +1 -0
  71. package/dist/storage.d.ts +52 -0
  72. package/dist/storage.d.ts.map +1 -0
  73. package/dist/storage.js +221 -0
  74. package/dist/storage.js.map +1 -0
  75. package/dist/testing.d.ts +19 -0
  76. package/dist/testing.d.ts.map +1 -0
  77. package/dist/testing.js +23 -0
  78. package/dist/testing.js.map +1 -0
  79. package/dist/transaction-guard.d.ts +2 -0
  80. package/dist/transaction-guard.d.ts.map +1 -0
  81. package/dist/transaction-guard.js +38 -0
  82. package/dist/transaction-guard.js.map +1 -0
  83. package/dist/types.d.ts +414 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +2 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/worker.d.ts +109 -0
  88. package/dist/worker.d.ts.map +1 -0
  89. package/dist/worker.js +458 -0
  90. package/dist/worker.js.map +1 -0
  91. package/dist/write.d.ts +39 -0
  92. package/dist/write.d.ts.map +1 -0
  93. package/dist/write.js +287 -0
  94. package/dist/write.js.map +1 -0
  95. package/docs/agent-guide.md +324 -0
  96. package/llms.txt +40 -0
  97. package/package.json +99 -4
  98. package/src/api.ts +465 -0
  99. package/src/command.ts +242 -0
  100. package/src/debug.ts +8 -0
  101. package/src/dot.ts +296 -0
  102. package/src/entity.ts +377 -0
  103. package/src/errors.ts +134 -0
  104. package/src/event.ts +26 -0
  105. package/src/fold.ts +97 -0
  106. package/src/handlers.ts +262 -0
  107. package/src/history.ts +390 -0
  108. package/src/hooks.ts +85 -0
  109. package/src/id.ts +54 -0
  110. package/src/index.ts +76 -0
  111. package/src/journal.ts +256 -0
  112. package/src/listen.ts +109 -0
  113. package/src/orpc.ts +59 -0
  114. package/src/pg.ts +107 -0
  115. package/src/storage.ts +277 -0
  116. package/src/test-support.ts +82 -0
  117. package/src/testing.ts +23 -0
  118. package/src/transaction-guard.ts +40 -0
  119. package/src/types.ts +439 -0
  120. package/src/worker.ts +609 -0
  121. package/src/write.ts +434 -0
@@ -0,0 +1,324 @@
1
+ # @arki/journal for agents
2
+
3
+ Read this file first. [`llms.txt`](../llms.txt) is the index. The [README](../README.md) is the long overview. `src/api.ts` defines every signature. If this guide and `src/api.ts` disagree, `src/api.ts` wins.
4
+
5
+ Paths that start with `src/` are files of this package. App files are named from the app's own source folder, for example `journal.root.ts` or `features/orders/effects.ts`. The app layout, with a "Which file do I touch" list, is in the [feature layout note](../../../apps/games-platform/backend/src/features/README.md) of the games-platform backend. Read it before you touch an app.
6
+
7
+ ## The model
8
+
9
+ 1. An entity is a normal row in its own Drizzle table. The table has a string key and a `version` column.
10
+ 2. Every change is also one row in `journal_events`. Event rows are never changed or deleted.
11
+ 3. One transaction writes the event rows and the entity row together.
12
+ 4. A command is the way to write. `j.command` defines one. `journal.run` runs it. `journal.bind` is the door: it fixes the database and the actor once and returns a runner.
13
+ 5. Inside a command, `write(entity, id, events)` locks the row and calls your `events` function with it. That function checks the rules and returns the new events.
14
+ 6. A reducer turns each event into the next row. `.create` builds the first row. `.on` returns a patch.
15
+ 7. Lane one is the projection. It runs inside the same transaction. If it throws, the write rolls back.
16
+ 8. Lane two is the effect. The write adds a row to `journal_jobs`. A worker runs the handler after the commit, at least once.
17
+ 9. Reads are plain Drizzle queries on the entity table.
18
+ 10. Replay rebuilds a row from its events. It is for checks and repair only, never for reads.
19
+
20
+ ## Three objects
21
+
22
+ | Object | How you get it | What it does | Where it lives in an app |
23
+ | --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
24
+ | `j`, the definitions | `initJournal` (see Setup) | Declares things: `j.event`, `j.entity`, `j.command`, `j.projection`, `j.effect`. It never touches a database. | `journal.ts` |
25
+ | The entity builder | `j.entity(kind, table)` | A chain of `.create(Event, reducer)` and `.on(Event, reducer)`. The result is the entity you pass to `write`. | `features/<name>/<entity>.ts` |
26
+ | `journal`, the runner | `j.compose({ tables, projections, effects })` | Does the work: `run`, `bind`, `write`, `replay`, `verify`, `repair`, `rerun`, `drain`, `worker`, `wake`. | `journal.root.ts`, or the Dot plugin |
27
+
28
+ How to tell them apart: only `journal` takes a database. Events, entities, commands, projections and effects need only `j`. They must never import the composed `journal`.
29
+
30
+ The word `create` has two meanings. Neither one builds the journal. `j.compose(...)` builds the journal.
31
+
32
+ | You see | It means | Defined in |
33
+ | ------------------------------------------------------ | ----------------------------------------------------------------- | ---------------- |
34
+ | `initJournal.tx<AppTx>().db<AppDb>().create({ meta })` | Finish the setup chain. Returns `j`. | `src/journal.ts` |
35
+ | `.create(Event, reducer)` on an entity builder | Declare a create reducer. It builds the first row from the event. | `src/entity.ts` |
36
+
37
+ ## Setup
38
+
39
+ ```ts
40
+ import { initJournal, need } from '@arki/journal';
41
+ import { journalTables } from '@arki/journal/pg';
42
+
43
+ // journal.ts: once per app. `meta` is the schema of the metadata every event records.
44
+ export const j = initJournal
45
+ .tx<AppTx>()
46
+ .db<AppDb>()
47
+ .create({ meta: z.object({ actorId: z.string().min(1) }) });
48
+
49
+ // schema.ts: the two journal tables go into the app's schema and migrations.
50
+ export const tables = journalTables(); // or journalTables(pgSchema('app'))
51
+
52
+ // journal.root.ts: the one place that lists handlers.
53
+ // `services` gives each effect what it needs, by name.
54
+ export const journal = j.compose({ tables, projections: [customerStats], effects: [orderMail], services: { mailer } });
55
+ ```
56
+
57
+ `.tx<AppTx>()` types `tx` in commands and projections. `.db<AppDb>()` types `db` in effects. Both steps are optional and type-only. `initJournal({ meta })` alone also returns `j`. `AppDb` is the app's Drizzle database type. `AppTx` is its transaction type: `Parameters<Parameters<AppDb['transaction']>[0]>[0]`.
58
+
59
+ ## Task map
60
+
61
+ ### Add an event and react in the same transaction
62
+
63
+ Library files: `src/entity.ts` (the builder and reducer types), `src/handlers.ts` (how a handler is found by event type).
64
+
65
+ 1. In the entity file, declare the event with `j.event(type, schema)`. Event data is stored as JSON, so use no `Date` and no transform.
66
+ 2. Add `.on(Event, reducer)` to the entity chain. The reducer returns only the columns that change.
67
+ 3. In a command, return the event from the `events` function.
68
+ 4. In the projections file, add a handler under the event's type. It gets `(tx, e)`. Use `tx` for every query.
69
+ 5. A new projection must be listed: under `projections` in `journal.root.ts`, or in the feature's `journalHandlers` in a Dot app.
70
+
71
+ A full projection, `customerStats`, is in the [README example](../README.md#example).
72
+
73
+ ### Add an entity with create and delete
74
+
75
+ Library files: `src/entity.ts` (the key column checks), `src/fold.ts` (how reducers are picked and applied).
76
+
77
+ 1. Add the table. It needs a string key that is the primary key or unique, and `version: integer('version').notNull().default(0)`. Add the migration in the app.
78
+ 2. The key column is `id`. For another column, name it: `j.entity('profile', profiles, { key: 'accountId' })`.
79
+ 3. Write the chain. Every `.create` comes before the first `.on`.
80
+ 4. To delete, return `null` from a reducer. The row is removed. The events stay. The entity can be created again later, and its version continues.
81
+ 5. A soft delete is a normal patch, such as `{ status: 'cancelled' }`. The row stays. The library does nothing special for it.
82
+
83
+ ```ts
84
+ export const order = j
85
+ .entity('order', orders)
86
+ .create(OrderPlaced, e => ({ customerId: e.data.customerId, total: e.data.total, status: 'placed' }))
87
+ .on(OrderPaid, () => ({ status: 'paid' })) // a patch: only the columns that change
88
+ .on(OrderCancelled, () => ({ status: 'cancelled' })) // soft delete: a normal patch, the row stays
89
+ .on(OrderErased, () => null); // delete: null removes the row, the events stay
90
+ ```
91
+
92
+ ### Add an effect that runs after commit
93
+
94
+ Library files: `src/handlers.ts` (`effectFor` is `j.effect`, `need`, and `bindEffect`, which gives an effect its services), `src/worker.ts` (claims, retries, `drain`, the loop, `wake`), `src/journal.ts` (`createJournal` gives the worker `runOn`, which makes `ctx.run`).
95
+
96
+ ```ts
97
+ // features/orders/effects.ts: the effect lists what it needs. Each handler gets it in the context.
98
+ export const orderMail = j.effect(
99
+ 'order-mail',
100
+ order,
101
+ { mailer: need<Mailer>() },
102
+ {
103
+ OrderPaid: async (e, { jobId, db, mailer }) => {
104
+ const [row] = await db.select().from(orders).where(eq(orders.id, e.entityId));
105
+ if (!row) return;
106
+ await mailer.send({ template: 'order-paid', orderId: e.entityId, total: row.total, idempotencyKey: jobId });
107
+ },
108
+ },
109
+ );
110
+
111
+ // An effect that writes runs a command. It needs no closure over the journal.
112
+ export const closeAfterPayment = j.effect('close-after-payment', order, {
113
+ OrderPaid: (e, { run }) => run(closeOrder, { orderId: e.entityId }, { actorId: 'system' }),
114
+ });
115
+ ```
116
+
117
+ - The handler gets `(e, { jobId, signal, db, run })`. `db` is the root database given to `drain` or `worker`. Read committed rows with it.
118
+ - An effect that needs nothing has three arguments. An effect that needs a service has four: the third is `{ mailer: need<Mailer>() }`, with `need` from `@arki/journal`. The handler then reads `mailer` from its context, typed. Do not wrap an effect in a function to pass a service in.
119
+ - Where the service comes from: a plain app gives it once, in `j.compose({ ..., services: { mailer } })`. In a Dot app it is a service of the feature that lists the effect, and the feature declares it in `needs`. A missing service is a compile error. `j.compose` and the Dot boot also throw, and the message names the effect and the need.
120
+ - A need cannot be named `jobId`, `signal`, `db` or `run`. `j.effect` throws when the effect is defined.
121
+ - `run(command, input, meta)` is `journal.run` on that same database: its own transaction, committed before it returns. `meta` is required, and the library never picks an actor. It works the same in a plain app and in a Dot app.
122
+ - If the command throws, the effect fails: nothing of the command is committed and the job is retried. If the effect fails after `run` returned, the command stays committed and runs again on the retry. Make the command safe to run twice. The usual key is `jobId`.
123
+ - `jobId` is the same on every retry. An effect can run more than once, so pass `jobId` on as an idempotency key.
124
+ - Where it is registered: under `effects` in `journal.root.ts`, or in the feature's `journalHandlers` in a Dot app. Effect names are unique per journal.
125
+ - Who runs the worker: a plain app calls `journal.worker(db).start()` once per process and `stop()` on shutdown. In a Dot app the `journal(...)` plugin does both.
126
+ - Which process runs the worker: an effect runs in the process that runs the worker. If an effect talks to something that lives in memory (an event emitter, a socket list, a cache), run the worker in the process that holds it. A worker in another process claims the job and the in-memory listeners never hear it. Tests do not catch this, because a test is one process.
127
+ - `wake`: `journal.run(db, ...)` on the root database wakes the worker after its commit. After a transaction of your own, call `journal.wake()` yourself. Other processes find the job by polling.
128
+ - A failing effect does not reach the caller. Its error is stored on the job row (`last_error`). The job is retried and marked dead after 8 failed attempts.
129
+
130
+ ### Add a command and run it
131
+
132
+ Library files: `src/api.ts` (`Command`, `CommandContext`, `bind`), `src/command.ts` (how `journal.run` runs one), `src/errors.ts` (`CommandError`), `src/orpc.ts` (the oRPC error mapping).
133
+
134
+ ```ts
135
+ // features/orders/commands.ts
136
+ import { CommandError } from '@arki/journal';
137
+ // core/orpc.ts, in an oRPC app: once, after the app's own error middleware.
138
+ import { commandErrors } from '@arki/journal/orpc';
139
+
140
+ export const payOrder = j.command(
141
+ 'payOrder',
142
+ z.object({ orderId: orderIdSchema, amount: z.int().positive() }),
143
+ (input, { write }) =>
144
+ write(order, input.orderId, row => {
145
+ if (!row) throw new CommandError('NOT_FOUND', 'Order not found');
146
+ if (row.status !== 'placed') throw new CommandError('CONFLICT', `Order is already ${row.status}`);
147
+ return [OrderPaid({ amount: input.amount })]; // [] means: write nothing
148
+ }),
149
+ );
150
+
151
+ // The door. Bind the database and the actor once, then pass only the command and its input.
152
+ // A request: the actor comes from the session. Put the bound runner on the request context.
153
+ const runCommand = journal.bind(db, { actorId: user.id });
154
+ await runCommand(payOrder, { orderId: id, amount: 10 });
155
+ // A job, a schedule, a script or a seed: use a clear system actor.
156
+ const runSystemCommand = journal.bind(db, { actorId: 'system' });
157
+ await runSystemCommand(payOrder, { orderId: id, amount: 10 });
158
+
159
+ export const publicProcedure = os.use(appErrors).use(commandErrors());
160
+ ```
161
+
162
+ - The handler context is `{ tx, meta, write, run }`. `run(child, input)` runs another command in a savepoint.
163
+ - `journal.bind(source, meta)` is the door. It returns `(command, input) => journal.run(source, command, input, meta)`. You still give `meta`: the library never picks an actor. `meta` is parsed on every call, as in `journal.run`, so a default in the meta schema is new for each command.
164
+ - `source` is the root database or a transaction, with the rules of `journal.run`. On the root database each call commits and wakes the worker. On a transaction the call joins it; the caller commits and then calls `journal.wake()`.
165
+ - A bound runner has the type `CommandRunner`, the type of a command context's `run`. Pass it to code that takes one, for example `paymentsJournal.writes(runCommand)` of `@arki/payments`.
166
+ - A rule that rejects the request throws `new CommandError(code, message)`. The command rolls back and the caller gets the same error. The four codes are in [Rejected rules](#rejected-rules-commanderror).
167
+ - An oRPC app adds `commandErrors()` to its procedure builders once. Procedures then need no try/catch around a command. Any other host matches with `isCommandError(error, code?)`.
168
+ - `journal.run(source, command, input, meta)` is the long form. It parses the input and `meta`, opens the transaction, and returns what the handler returns.
169
+ - From an effect, call `ctx.run(command, input, meta)`. It is `journal.run` on the worker's root database.
170
+ - In the games-platform backend, procedures call `context.runCommand`. `core/context.ts` binds it to the signed-in user. A job or a schedule binds its own runner: `journal.bind(db, { actorId: null })`. Do not call `journal.run` from a procedure there.
171
+
172
+ ### Find out why a write failed
173
+
174
+ 1. Check the kind of error. `isJournalError(error)` is true: go to step 2. `isCommandError(error)` is true: a rule of the app rejected the request; read its `code` and `message`, and find the `throw` in the command. A `ZodError`: input, event data or `meta` failed its schema. A plain `Error` that starts with `journal:`: a wrong definition or a misuse; search `src/` for the message. Anything else was thrown by app code and passed through unchanged.
175
+ 2. Read `error.code`, `error.kind` and `error.entityId`. Match on the code, for example `isJournalError(error, 'CONFLICT')`. Never match on the message. The message reads `CODE: what happened (kind id): detail. What to do next.`
176
+ 3. Find the code in the [error table](#errors). To see the exact check, search for the code in the file the table names.
177
+ 4. Run again with `DEBUG=arki:journal:*`. It prints commands, writes, replays, drains and worker ticks.
178
+
179
+ ## Words that are easy to mix up
180
+
181
+ | Pair | The difference |
182
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
+ | Stored row and reducer row | The stored row (`Row<Tbl>`) is what the database returns, with defaults filled in. `events(row)` gets it. The reducer row (`ReducerRow<Tbl, Key>`) is what reducers built. A column left to its database default may be missing. `.on` and `replay` use it. |
184
+ | Pending event and recorded event | A pending event is `{ type, data }`. `OrderPaid({ amount })` makes one and `events` returns them. A recorded event also has `seq`, `id`, `entityId`, `version`, `meta`, `recordedAt`, the trace `commandId`, `causeSeq`, `correlationId` (library-stamped; see README, Tracing), and `erasedAt` (see README, Erasure). Reducers, projections and effects get recorded events. |
185
+ | Entity version and global sequence | `version` counts 1, 2, 3 inside one entity. `replay` takes `throughVersion`. `seq` orders all events of all entities in commit order: an advisory lock taken at the start of every write makes a lower `seq` commit first, so `seq > cursor` misses nothing. `rerun` takes `afterSeq`. |
186
+ | `DbHandle`, `RootDb`, `TransactionSource` | `DbHandle` fits a database or a transaction: `replay`, `verify`, `repair`. `RootDb` is the database only: `drain`, `worker`. `TransactionSource<Tx>` is anything that opens a `Tx`: `run`, `rerun`. A root database opens a transaction. A transaction opens a savepoint. |
187
+
188
+ ## Rules that bite
189
+
190
+ - **Await every `write` and `run`. One journal operation per transaction at a time.** Why: savepoints are a stack, so two open operations would release each other's savepoint. No `Promise.all` over writes.
191
+ - **`drain` and `worker` take the root database.** Why: inside a transaction, effects would run before the outer commit. They throw if given a transaction.
192
+ - **Run the worker where its effects have their listeners.** Why: any running worker can claim any job. An effect that emits in memory reaches only the process it runs in.
193
+ - **A reducer returns the value the database will store.** Why: `replay` and `repair` rebuild the row from reducer output. If the database rounds or trims, the two differ. The write fails with `VALUE_MISMATCH`.
194
+ - **Events are frozen.** Why: one handler's change would reach the next handler, and replay would not repeat it. Copy first: `[...e.data.items].sort()`.
195
+ - **Version 1 means created by the journal.** A row that existed before the journal starts its history at version 2. Why: replay folds from nothing, so it needs a history that starts at 1. Otherwise `replay`, `verify` and `repair` throw `NO_HISTORY`. To bring a legacy row in with a complete history, `journal.adopt` records a create event that rebuilds the row exactly as version 1 (README, Rows that existed before the journal).
196
+ - **Pass the id exactly as it is stored.** Why: the log compares ids as exact strings. A second spelling would split one history in two. The write fails with `ID_MISMATCH`.
197
+ - **A reducer sets every column that a later reducer reads.** Why: database defaults are not in the log, so replay does not see them.
198
+ - **For a new meaning, add a new event type.** Why: `replay` and `repair` use today's reducers on old events.
199
+
200
+ ## Errors
201
+
202
+ Two kinds of error have a code.
203
+
204
+ - A `JournalError` is the library's own failure. It is about one entity's row or history. It has `code`, `kind` and `entityId`. Its twelve codes are in the table below.
205
+ - A `CommandError` is a rule of the app that rejects a request. App code throws it; the library never does. It is not a `JournalError`. Its four codes are in [Rejected rules](#rejected-rules-commanderror).
206
+
207
+ This is the only copy of this table. The message texts are in `src/errors.ts`. An error thrown by an effect never reaches the caller: `drain` stores it on the job row.
208
+
209
+ | Code | What it means | Usual cause | What to do | Thrown in |
210
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
211
+ | `CONFLICT` | The row and its event history cannot accept this write. The caller's `expectedVersion` is stale, another writer appended first, or the row is missing or behind while its log says otherwise. | Two requests wrote the same entity at the same time. Or someone deleted the row by hand. | After a concurrent append, run the command again on the current state. When the row and the history disagree, check with `verify`, and use `repair` only when the history is complete. | `src/write.ts`, `src/history.ts` |
212
+ | `NO_ROW` | No row exists, and the event has no `.create` reducer. | A wrong id, or the entity was deleted, and `events` did not check for a missing row. | Create the entity first, or declare `.create` for the event. | `src/write.ts`, `src/fold.ts` |
213
+ | `ROW_EXISTS` | A row exists, and the event has no `.on` reducer. | A create event was written twice for one id. | Write an update event, or declare `.on` for the event. | `src/write.ts`, `src/fold.ts` |
214
+ | `UNKNOWN_EVENT` | The entity does not declare the event. | A hand-built `{ type, data }`, or an event that belongs to another entity. | Add `.create` or `.on` for it on this entity, or write it to the entity that declares it. | `src/write.ts`, `src/fold.ts` |
215
+ | `VERSION_MISMATCH` | When saving, no row matched the version this write locked. | A direct update or delete of the entity row inside `events`. | Look for a statement that changes the row version or removes the row outside the journal, for example a direct update inside `events`. Then run the command again. | `src/write.ts` |
216
+ | `NO_HISTORY` | The log has no complete history from version 1. The row existed before the journal, or events are missing. | The row came from a seed or an old migration, not from a create event. | `replay`, `verify` and `repair` cannot rebuild this row. Check how the row was created, or restore the missing events. | `src/history.ts` |
217
+ | `HISTORY_GAP` | The log has a hole (a version between 1 and the top is missing), or the stored row is at a higher version than the log's top while the log says the entity is alive. `replay`, `verify` and `repair` throw it. A row over a log that ends in a delete is not a gap: it was placed outside the journal, and `repair` removes it. | An event row was deleted by hand, or lost in a restore from backup. | Restore the missing event from a backup, or accept the row as it is. Replay and repair refuse to build a row the real history never produced. | `src/history.ts` |
218
+ | `REPLAY_FAILED` | A stored event cannot be decoded or applied: its type is not declared, its data or `meta` does not fit the current schema, or no reducer fits it. `replay`, `verify`, `repair` and `rerun` throw it. `drain` records it on the job. | An event type was renamed or removed, or its schema was made stricter, after events were stored. | Check the event schema and the reducers against the stored history. | `src/history.ts`, `src/fold.ts` |
219
+ | `UNSTABLE_SCHEMA` | Event data or `meta` changes, or fails, across JSON storage and a second parse. | A `Date` or a transform in an event schema or in the `meta` schema. | Return JSON-compatible data that the schema accepts unchanged. | `src/storage.ts` |
220
+ | `ID_MISMATCH` | The id is empty, malformed, too long, or spelled differently from the stored key. | An upper-case `uuid`, an empty string, or an id longer than the key column. | Pass the id exactly as stored. A `uuid` must be lower-case and hyphenated. | `src/storage.ts`, `src/write.ts` |
221
+ | `BAD_DEFINITION` | A declaration is wrong. It is thrown when the declaring code runs (import time), before any data is touched. | A bad event type, entity kind or effect name; a bad key column; `.create` after `.on`; a second reducer for one event; a need named `db`; two effects or projections with one name; a missing service in `compose`. | Fix the declaration the message names. The message text is the detail after the code. | `src/id.ts`, `src/entity.ts`, `src/handlers.ts`, `src/journal.ts`, `src/dot.ts` |
222
+ | `VALUE_MISMATCH` | The database stored a different value than the reducer produced. The write was rolled back. | A `numeric` value with more decimals than the column keeps, such as `"1.234"` for `numeric(10,2)`. | Make the reducer produce the value in the form the column stores. | `src/storage.ts` |
223
+
224
+ ### Rejected rules: `CommandError`
225
+
226
+ `new CommandError(code, message)` is thrown by a command handler or by an `events` function. The command rolls back, and `journal.run` lets the same error out. `isCommandError(error, code?)` matches it. The message is written for the client.
227
+
228
+ | Code | HTTP | Use it when |
229
+ | ------------- | ---- | ---------------------------------------------------------------------------- |
230
+ | `NOT_FOUND` | 404 | The thing the request names does not exist. |
231
+ | `BAD_REQUEST` | 400 | The input has the right shape, but a rule refuses it. An end before a start. |
232
+ | `FORBIDDEN` | 403 | This actor may not do this to this entity. |
233
+ | `CONFLICT` | 409 | The current state does not allow it. Already paid. A name that is taken. |
234
+
235
+ `@arki/journal/orpc` maps these for an oRPC app. The code is in `src/orpc.ts`. `@orpc/server` is an optional peer, and the core entry never imports it.
236
+
237
+ - `toORPCError(error)` turns a `CommandError` into an `ORPCError` with the same code and message.
238
+ - It turns a `JournalError` with code `CONFLICT` into an `ORPCError('CONFLICT')` with the message `This was changed by another request. Reload the current state and try again.` The client reloads first: with an `expectedVersion`, the same request fails again.
239
+ - It returns every other error unchanged.
240
+ - `commandErrors()` is the same mapping as a middleware. Put it after the app's own error middleware: `os.use(appErrors).use(commandErrors())`. Put before it, the app's middleware sees a plain error first and answers 500.
241
+
242
+ ### Dot boot errors
243
+
244
+ - `JOURNAL_PLUGIN_E001`, a `DotPluginError`: a feature token holds a `journal` slice that `journalHandlers` did not make. Declare the slice with `journalHandlers({ projections, effects })`.
245
+ - `journal: duplicate effect name "x" (features "feature.orders" and "feature.billing")`: two features list a handler with the same name. Rename one. Projections get the same message with `projection`.
246
+ - `journal: effect "x" needs "mailer", but no service with that name was given; ...`: thrown by `j.compose` in a plain app and at boot in a Dot app. Plain app: add `mailer` to `services` in `j.compose`. Dot app: add it to the `needs` of the feature that lists the effect.
247
+ - `journal: effect "x" has a need named "db", ...`: thrown by `j.effect`. The names `jobId`, `signal`, `db` and `run` are taken. Give the need another name.
248
+
249
+ ## Recovery verbs
250
+
251
+ | Verb | Takes | Returns | When to use it |
252
+ | ------------------------------------------------ | ---------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
253
+ | `adopt(tx, entity, id, { meta, events })` | A transaction | `{ row, events }` as `write` returns. | Once per row that existed before the journal, before its first write. `events(row)` returns one create event whose `.create` reducer rebuilds the stored row exactly; it becomes version 1. |
254
+ | `forget(db, entity, id)` | A database or a transaction | `{ erased, row }`: how many events were rewritten, and the repaired row. | To remove personal data from one entity's history. Events with an `erased` form are rewritten to it and stamped; the row is repaired to match. Read models are the app's to forget. |
255
+ | `replay(db, entity, id, { throughVersion })` | A database or a transaction | `{ row, events }`: the reducer row built in memory, or `null`, and its events. | To see what the log says about one entity. It writes nothing. |
256
+ | `verify(db, entity, id)` | A database or a transaction | `{ ok: true }`, or `{ ok: false, expected, actual }`. | To check one stored row against its log. It compares only the columns that reducers set. |
257
+ | `repair(db, entity, id)` | A database or a transaction | The stored row after the repair, or `null`. | The row is wrong and the history is complete. It writes no event, runs no projection and creates no job. |
258
+ | `rerun(db, projection, { afterSeq, batchSize })` | A database that opens transactions | `{ events }`: how many events it handled. | To fill a new read model or rebuild a lost one. Every handler runs again, so empty a counting read model first. |
259
+ | `drain(db, { batchSize, signal })` | The root database only | How many jobs it claimed. | To run one batch of due jobs by hand, in a test or a script. The worker calls it in a loop. |
260
+
261
+ ## Dot
262
+
263
+ `@arki/journal/dot` exports the plugin `journal(...)` and the feature slice `journalHandlers(...)`. The code is in `src/dot.ts`.
264
+
265
+ | Question | Plain app | Dot app |
266
+ | ------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------- |
267
+ | Where handlers are listed | One `journal.root.ts` calls `j.compose(...)`. | Each feature lists its own with `journalHandlers(...)`. There is no root file. |
268
+ | Who builds the journal | Your code. | The plugin: `journal({ j, tables, features })`. It publishes `services.journal`. |
269
+ | Who runs the worker | Your code: `journal.worker(db)`, `start()`, `stop()`. | The plugin, between `start` and `stop`. Pass `worker: false` to turn it off. |
270
+ | What an effect needs | `services` in `j.compose(...)`, once for the app. | The services of the feature that lists the effect. It declares them in `needs`. |
271
+
272
+ | Where the door is bound | The request context: `journal.bind(db, { actorId })`. | The gateway's context factory: `services.journal.bind(services.db, { actorId })`. |
273
+
274
+ The code for both wirings is in the [README, Dot](../README.md#dot).
275
+
276
+ The gateway binds the door once per request. Its context factory reads the session and puts `run: services.journal.bind(services.db, { actorId })` on the context. A procedure then calls `context.run(command, input)`. The plugin does not bind for you: only the app knows the actor.
277
+
278
+ An effect runs a command with `ctx.run` in both wirings. In feature mode the journal is built after the features, and that is fine: the journal hands `run` to the effect when the job runs.
279
+
280
+ ## Shared packages
281
+
282
+ This is the pattern to follow. No code in this repo uses it yet. A package that ships journal pieces exports a factory. The factory takes the app's `j` and the tables. It returns entities, commands, projections and effects. An effect that needs a service lists it with `need<T>()`, and the app gives the service in its own `j.compose({ services })`. The package never owns a database, a composed journal or a worker. The app adds the tables to its schema. It calls `billingJournal(j, billingTables())` and lists the returned handlers in its own `j.compose(...)`. `billingTables()` is the package's own table factory, shaped like `journalTables()`: it returns `{ invoices }`, a table with a string `id` and a `version` column.
283
+
284
+ ```ts
285
+ export function billingJournal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
286
+ j: JournalDefinitions<Meta, Tx, AppDb>,
287
+ tables: ReturnType<typeof billingTables>,
288
+ ) {
289
+ const InvoiceIssued = j.event('InvoiceIssued', z.object({ amount: z.int().positive() }));
290
+ const invoice = j.entity('invoice', tables.invoices).create(InvoiceIssued, e => ({ amount: e.data.amount }));
291
+ const issueInvoice = j.command(
292
+ 'issueInvoice',
293
+ z.object({ invoiceId: z.string().min(1), amount: z.int().positive() }),
294
+ (input, { write }) => write(invoice, input.invoiceId, () => [InvoiceIssued({ amount: input.amount })]),
295
+ );
296
+ const invoiceMail = j.effect(
297
+ 'invoice-mail',
298
+ invoice,
299
+ { sendInvoiceMail: need<(invoiceId: string, idempotencyKey: string) => Promise<void>>() },
300
+ { InvoiceIssued: (e, { jobId, sendInvoiceMail }) => sendInvoiceMail(e.entityId, jobId) },
301
+ );
302
+ return { entities: { invoice }, events: { InvoiceIssued }, commands: { issueInvoice }, effects: { invoiceMail } };
303
+ }
304
+ ```
305
+
306
+ ## Do not clean these up
307
+
308
+ These look odd. Each one is needed.
309
+
310
+ - `ownHandler` with `Object.hasOwn` in `src/handlers.ts`: an event type named `toString` must not find a handler on the prototype.
311
+ - `bindEffect` in `src/handlers.ts` runs for every effect, also for one without needs, and a Dot app runs it twice (the bridge, then `j.compose`): it is the one place where a need gets its service and where a missing service is caught.
312
+ - `need<T>()` is the journal's own marker, not Dot's `service<T>()`: the core must load and type-check without `@arki/dot`, and Dot keeps the key of its marker private.
313
+ - `const E` on `compose` (`src/api.ts`) and `journalHandlers` (`src/dot.ts`): without it TypeScript reads `[plainEffect, effectWithNeeds]` as a list of plain effects, and a missing service is no longer a compile error.
314
+ - The module-scoped `WeakMap` in `src/transaction-guard.ts`: two journals on one transaction share its savepoint stack, so they must share this guard.
315
+ - `assertRoundTrips` in `src/storage.ts` uses `JSON.stringify`, not `structuredClone`: it checks what JSON storage does to the value.
316
+ - Delete, then insert, when a row is deleted and created again in one write (`src/write.ts`): columns the create leaves out must get their defaults, not the old values.
317
+ - `dbNowPlus` and every `now()` in `src/worker.ts`: lease and retry times use the database clock, because process clocks differ.
318
+ - A savepoint around every `write` and every child `run`: a failure the caller catches rolls back only that operation.
319
+ - `decodeStoredEvent` after the insert in `src/write.ts`: reducers and projections see the event exactly as replay will read it.
320
+ - `deepFreeze` in `src/storage.ts`: it is what makes events frozen.
321
+ - `definedOnly` in `src/entity.ts`: a patch key set to `undefined` means unchanged.
322
+ - `isUniqueViolation` in `src/write.ts` walks `cause` five levels deep: drivers wrap the Postgres error.
323
+ - `assertRootDb` in `src/storage.ts`: the type check alone cannot stop every transaction, so `drain` and `worker` also check at run time.
324
+ - No `.create` after the first `.on` (`OnOnlyEntityBuilder` in `src/entity.ts`): a later create could leave out a column that an `.on` reducer was promised.
package/llms.txt ADDED
@@ -0,0 +1,40 @@
1
+ # @arki/journal
2
+
3
+ > A journal for Drizzle entities. Every change is one immutable event row and one normal table row, written in one transaction. Projections run inside that transaction. Effects run after the commit, from a job table.
4
+
5
+ ## Import paths
6
+ - `@arki/journal`: the core. `initJournal`, `need`, `CommandError`, `isCommandError`, `JournalError`, `isJournalError` and the types.
7
+ - `@arki/journal/pg`: `journalTables()`, the two tables (`journal_events`, `journal_jobs`) for the app's schema.
8
+ - `@arki/journal/dot`: optional. The `@arki/dot` plugin `journal(...)` and the feature slice `journalHandlers(...)`.
9
+ - `@arki/journal/orpc`: optional. The oRPC middleware `commandErrors()` and the function `toORPCError(error)`.
10
+
11
+ ## Start here
12
+ - [Agent guide](docs/agent-guide.md): the model, the three objects, the task map, the rules, the error table. Read it first.
13
+
14
+ ## By task
15
+ - [Add an event and react in the same transaction](docs/agent-guide.md#add-an-event-and-react-in-the-same-transaction)
16
+ - [Add an entity with create and delete](docs/agent-guide.md#add-an-entity-with-create-and-delete)
17
+ - [Add an effect that runs after commit](docs/agent-guide.md#add-an-effect-that-runs-after-commit)
18
+ - [Add a command and run it](docs/agent-guide.md#add-a-command-and-run-it)
19
+ - [Find out why a write failed](docs/agent-guide.md#find-out-why-a-write-failed), with the [error table](docs/agent-guide.md#errors) and the [recovery verbs](docs/agent-guide.md#recovery-verbs)
20
+ - [Wire it in a Dot app](docs/agent-guide.md#dot), or [ship journal pieces from a shared package](docs/agent-guide.md#shared-packages)
21
+
22
+ ## Files
23
+ - Public surface: `src/index.ts` (the exports), `src/api.ts` (every public signature), `src/types.ts` (row, event, handler and database types), `src/errors.ts` (the twelve codes and their messages, and `CommandError`), `src/hooks.ts` (around-hooks on command and effect runs).
24
+ - Definitions: `src/journal.ts` (`initJournal`, `j`, `j.compose`), `src/entity.ts` (entity builder, reducers, key checks), `src/event.ts` (`j.event`), `src/handlers.ts` (`j.projection`, `j.effect`, `need`, and `bindEffect`, which gives an effect its services).
25
+ - Write path: `src/command.ts` (`journal.run`), `src/journal.ts` (`journal.bind`),`src/write.ts` (the write steps), `src/fold.ts` (apply events to a row), `src/storage.ts` (load and save the row, id and value checks), `src/transaction-guard.ts` (one operation per transaction).
26
+ - After commit and repair: `src/worker.ts` (`drain`, the worker loop, `wake`, `strandedJobs`), `src/history.ts` (`replay`, `verify`, `repair`, `rerun`).
27
+ - Edges: `src/pg.ts` (the two tables), `src/dot.ts` (Dot plugin), `src/orpc.ts` (oRPC error mapping),`src/id.ts` (id factories, name checks), `src/debug.ts` (`DEBUG=arki:journal:*`), `src/test-support.ts` (test fixture, not shipped).
28
+
29
+ ## Rules that bite
30
+ - The door is `journal.bind(db, meta)`: bind once per request or job, then call `run(command, input)`. You give `meta`; the library never picks an actor.
31
+ - A rule that rejects a request throws `CommandError(code, message)`. It is not a `JournalError`. An oRPC app adds `commandErrors()` once, after its own error middleware.
32
+ - Await every `write` and `run`. One journal operation per transaction at a time. No `Promise.all` over writes.
33
+ - `drain` and `worker` take the root database, never a transaction.
34
+ - An effect that needs a service lists it: `j.effect(name, entity, { mailer: need<Mailer>() }, on)`. The handler reads `mailer` from its context. Do not wrap an effect in a function. A plain app gives the services once, in `j.compose({ services })`. In a Dot app the feature that lists the effect declares them in `needs`.
35
+ - Any running worker can claim any job. Run the worker in the process that holds what its effects talk to (for example an in-memory emitter).
36
+ - A reducer returns the value the database will store, or the write fails with `VALUE_MISMATCH`. Returning `null` deletes the row. A soft delete is a normal patch.
37
+ - Version 1 means created by the journal. A row that existed before the journal starts at version 2.
38
+ - Pass ids exactly as they are stored. Events are frozen: copy before you change.
39
+
40
+ > The design spec (`docs/specs/2026-10-09-journal-design.md`) explains intent. `src/api.ts` defines the signatures. When they differ, `src/api.ts` is right.
package/package.json CHANGED
@@ -1,6 +1,101 @@
1
1
  {
2
2
  "name": "@arki/journal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A journal for Drizzle entities: every change is an immutable event row, the current state is a normal table row, and both are written in one transaction. Inline projections run in the same transaction; effects run after commit from a job table.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/arki-labs/arki.git"
9
+ },
10
+ "homepage": "https://arki.dev",
11
+ "bugs": {
12
+ "url": "https://github.com/arki-labs/arki/issues"
13
+ },
14
+ "keywords": [
15
+ "arki",
16
+ "typescript",
17
+ "journal",
18
+ "events",
19
+ "event-log",
20
+ "drizzle",
21
+ "postgres",
22
+ "projection",
23
+ "jobs"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./pg": {
34
+ "types": "./dist/pg.d.ts",
35
+ "import": "./dist/pg.js",
36
+ "default": "./dist/pg.js"
37
+ },
38
+ "./dot": {
39
+ "types": "./dist/dot.d.ts",
40
+ "import": "./dist/dot.js",
41
+ "default": "./dist/dot.js"
42
+ },
43
+ "./errors": {
44
+ "types": "./dist/errors.d.ts",
45
+ "import": "./dist/errors.js",
46
+ "default": "./dist/errors.js"
47
+ },
48
+ "./orpc": {
49
+ "types": "./dist/orpc.d.ts",
50
+ "import": "./dist/orpc.js",
51
+ "default": "./dist/orpc.js"
52
+ },
53
+ "./testing": {
54
+ "types": "./dist/testing.d.ts",
55
+ "import": "./dist/testing.js",
56
+ "default": "./dist/testing.js"
57
+ },
58
+ "./package.json": "./package.json"
59
+ },
60
+ "files": [
61
+ "dist/**",
62
+ "src/**",
63
+ "!src/**/*.test.*",
64
+ "!src/**/*.spec.*",
65
+ "!src/**/__tests__/**",
66
+ "README.md",
67
+ "llms.txt",
68
+ "docs/agent-guide.md",
69
+ "LICENSE",
70
+ "package.json"
71
+ ],
72
+ "dependencies": {
73
+ "@arki/log": "0.1.0",
74
+ "@paralleldrive/cuid2": "^3.3.0",
75
+ "drizzle-orm": "1.0.0-rc.4",
76
+ "zod": "4.6.5"
77
+ },
78
+ "peerDependencies": {
79
+ "@arki/dot": "^0.5.0",
80
+ "@electric-sql/pglite": "^0.4.6",
81
+ "@orpc/server": "^1.15.5",
82
+ "drizzle-kit": "1.0.0-rc.4"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "@arki/dot": {
86
+ "optional": true
87
+ },
88
+ "@electric-sql/pglite": {
89
+ "optional": true
90
+ },
91
+ "@orpc/server": {
92
+ "optional": true
93
+ },
94
+ "drizzle-kit": {
95
+ "optional": true
96
+ }
97
+ },
98
+ "publishConfig": {
99
+ "access": "public"
100
+ }
101
+ }