@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.
- package/LICENSE +21 -0
- package/README.md +541 -2
- package/dist/api.d.ts +336 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -0
- package/dist/command.d.ts +27 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +157 -0
- package/dist/command.js.map +1 -0
- package/dist/debug.d.ts +7 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +8 -0
- package/dist/debug.js.map +1 -0
- package/dist/dot.d.ts +102 -0
- package/dist/dot.d.ts.map +1 -0
- package/dist/dot.js +159 -0
- package/dist/dot.js.map +1 -0
- package/dist/entity.d.ts +56 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +174 -0
- package/dist/entity.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +112 -0
- package/dist/errors.js.map +1 -0
- package/dist/event.d.ts +9 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +19 -0
- package/dist/event.js.map +1 -0
- package/dist/fold.d.ts +29 -0
- package/dist/fold.d.ts.map +1 -0
- package/dist/fold.js +49 -0
- package/dist/fold.js.map +1 -0
- package/dist/handlers.d.ts +26 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +130 -0
- package/dist/handlers.js.map +1 -0
- package/dist/history.d.ts +49 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +259 -0
- package/dist/history.js.map +1 -0
- package/dist/hooks.d.ts +14 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +67 -0
- package/dist/hooks.js.map +1 -0
- package/dist/id.d.ts +18 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +41 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +51 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +181 -0
- package/dist/journal.js.map +1 -0
- package/dist/listen.d.ts +37 -0
- package/dist/listen.d.ts.map +1 -0
- package/dist/listen.js +90 -0
- package/dist/listen.js.map +1 -0
- package/dist/orpc.d.ts +21 -0
- package/dist/orpc.d.ts.map +1 -0
- package/dist/orpc.js +57 -0
- package/dist/orpc.js.map +1 -0
- package/dist/pg.d.ts +537 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +80 -0
- package/dist/pg.js.map +1 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +221 -0
- package/dist/storage.js.map +1 -0
- package/dist/testing.d.ts +19 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +23 -0
- package/dist/testing.js.map +1 -0
- package/dist/transaction-guard.d.ts +2 -0
- package/dist/transaction-guard.d.ts.map +1 -0
- package/dist/transaction-guard.js +38 -0
- package/dist/transaction-guard.js.map +1 -0
- package/dist/types.d.ts +414 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +109 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +458 -0
- package/dist/worker.js.map +1 -0
- package/dist/write.d.ts +39 -0
- package/dist/write.d.ts.map +1 -0
- package/dist/write.js +287 -0
- package/dist/write.js.map +1 -0
- package/docs/agent-guide.md +324 -0
- package/llms.txt +40 -0
- package/package.json +99 -4
- package/src/api.ts +465 -0
- package/src/command.ts +242 -0
- package/src/debug.ts +8 -0
- package/src/dot.ts +296 -0
- package/src/entity.ts +377 -0
- package/src/errors.ts +134 -0
- package/src/event.ts +26 -0
- package/src/fold.ts +97 -0
- package/src/handlers.ts +262 -0
- package/src/history.ts +390 -0
- package/src/hooks.ts +85 -0
- package/src/id.ts +54 -0
- package/src/index.ts +76 -0
- package/src/journal.ts +256 -0
- package/src/listen.ts +109 -0
- package/src/orpc.ts +59 -0
- package/src/pg.ts +107 -0
- package/src/storage.ts +277 -0
- package/src/test-support.ts +82 -0
- package/src/testing.ts +23 -0
- package/src/transaction-guard.ts +40 -0
- package/src/types.ts +439 -0
- package/src/worker.ts +609 -0
- package/src/write.ts +434 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ARKI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,542 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @arki/journal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A journal keeps two things and writes them together.
|
|
4
|
+
Every change is one immutable row in `journal_events`.
|
|
5
|
+
Each entity also has a normal row in its own Drizzle table.
|
|
6
|
+
A command is the way to write: `j.command` defines one, `journal.run` runs it.
|
|
7
|
+
`journal.bind` is the door for a request or a job: it fixes the database and the actor once.
|
|
8
|
+
Inside the command, `write` validates the event data, appends the events, runs the reducers, saves the row, runs inline projections and queues jobs for async effects, all in one transaction.
|
|
9
|
+
Reads are plain Drizzle queries. Replay exists for checks and repair, not for reading.
|
|
10
|
+
|
|
11
|
+
Exports: `@arki/journal` (portable core), `@arki/journal/pg` (table definitions), `@arki/journal/errors` (`CommandError`, `JournalError` and their guards with no Drizzle import, for browser code that matches on codes), `@arki/journal/dot` (optional `@arki/dot` plugin and feature slice, see Dot), `@arki/journal/orpc` (optional oRPC error mapping, see Rejected rules), `@arki/journal/testing` (`openJournalDb(schema)`: an in-memory PGlite with the schema pushed, for tests; `@electric-sql/pglite` and `drizzle-kit` are optional peers).
|
|
12
|
+
|
|
13
|
+
**Agents: start at [`llms.txt`](llms.txt).** Then open the part of the [agent guide](docs/agent-guide.md) that fits the task:
|
|
14
|
+
|
|
15
|
+
| Task | Read |
|
|
16
|
+
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| Understand the model and the three objects (`j`, the entity builder, `journal`) | [The model](docs/agent-guide.md#the-model), [Three objects](docs/agent-guide.md#three-objects) |
|
|
18
|
+
| Set the journal up in an app | [Setup](docs/agent-guide.md#setup) |
|
|
19
|
+
| Add an event and react to it in the same transaction | [Add an event](docs/agent-guide.md#add-an-event-and-react-in-the-same-transaction) |
|
|
20
|
+
| Add an entity, with create and delete | [Add an entity](docs/agent-guide.md#add-an-entity-with-create-and-delete) |
|
|
21
|
+
| Add an effect that runs after commit | [Add an effect](docs/agent-guide.md#add-an-effect-that-runs-after-commit) |
|
|
22
|
+
| Add a command and run it from a request or a job | [Add a command](docs/agent-guide.md#add-a-command-and-run-it) |
|
|
23
|
+
| Find out why a write failed | [Find out why](docs/agent-guide.md#find-out-why-a-write-failed), [Errors](docs/agent-guide.md#errors) |
|
|
24
|
+
| Check or repair a row, rebuild a read model | [Recovery verbs](docs/agent-guide.md#recovery-verbs) |
|
|
25
|
+
| Wire it in a Dot app, or ship journal pieces from a package | [Dot](docs/agent-guide.md#dot), [Shared packages](docs/agent-guide.md#shared-packages) |
|
|
26
|
+
| Watch or stop every command or effect run from a plugin (tracing, logging, a circuit breaker) | [Hooks](#hooks): `journal.hooks.command.around`, `journal.hooks.effect.around` |
|
|
27
|
+
| Change code inside this package | [Rules that bite](docs/agent-guide.md#rules-that-bite), [Do not clean these up](docs/agent-guide.md#do-not-clean-these-up) |
|
|
28
|
+
|
|
29
|
+
## Example
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/journal.ts — once per app. `meta` is the app-wide metadata schema.
|
|
33
|
+
import { initJournal, need } from '@arki/journal';
|
|
34
|
+
// src/schema.ts — the two journal tables live in the app's schema.
|
|
35
|
+
import { journalTables } from '@arki/journal/pg';
|
|
36
|
+
|
|
37
|
+
export const j = initJournal({ meta: z.object({ actorId: z.string() }) });
|
|
38
|
+
// Or, to type `tx` everywhere with the app's own Drizzle transaction type:
|
|
39
|
+
// export const j = initJournal.tx<AppTx>().create({ meta: ... });
|
|
40
|
+
// And to type `db` inside effects with the app's own database type:
|
|
41
|
+
// export const j = initJournal.tx<AppTx>().db<AppDb>().create({ meta: ... });
|
|
42
|
+
|
|
43
|
+
// src/features/orders/tables.ts — a normal table with `id` and `version`.
|
|
44
|
+
// A table keyed by another column: j.entity('profile', profiles, { key: 'accountId' }).
|
|
45
|
+
export const orders = pgTable('orders', {
|
|
46
|
+
id: varchar('id', { length: 256 }).primaryKey().$type<OrderId>(),
|
|
47
|
+
customerId: varchar('customer_id', { length: 256 }).notNull(),
|
|
48
|
+
total: integer('total').notNull(),
|
|
49
|
+
status: varchar('status', { enum: ['placed', 'paid', 'cancelled'] }).notNull(),
|
|
50
|
+
version: integer('version').notNull().default(0),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// src/features/orders/order.ts — events and the entity.
|
|
54
|
+
export const OrderPlaced = j.event('OrderPlaced', z.object({ customerId: z.string(), total: z.int().positive() }));
|
|
55
|
+
export const OrderPaid = j.event('OrderPaid', z.object({ amount: z.int().positive() }));
|
|
56
|
+
export const OrderCancelled = j.event('OrderCancelled', z.object({ reason: z.string().min(1) }));
|
|
57
|
+
|
|
58
|
+
export const order = j
|
|
59
|
+
.entity('order', orders)
|
|
60
|
+
.create(OrderPlaced, e => ({ customerId: e.data.customerId, total: e.data.total, status: 'placed' }))
|
|
61
|
+
.on(OrderPaid, () => ({ status: 'paid' })) // a patch: only the columns that change
|
|
62
|
+
.on(OrderCancelled, () => null); // null deletes the row; the events stay
|
|
63
|
+
|
|
64
|
+
// src/features/orders/commands.ts — a command needs only `j`, entities and events.
|
|
65
|
+
export const payOrder = j.command(
|
|
66
|
+
'payOrder',
|
|
67
|
+
z.object({ orderId: orderIdSchema, amount: z.int().positive() }),
|
|
68
|
+
(input, { write }) =>
|
|
69
|
+
write(order, input.orderId, row => {
|
|
70
|
+
if (!row) throw new CommandError('NOT_FOUND', 'Order not found');
|
|
71
|
+
if (row.status !== 'placed') throw new CommandError('CONFLICT', `Order is already ${row.status}`);
|
|
72
|
+
return [OrderPaid({ amount: input.amount })]; // [] means: write nothing
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Inline lane: exact read model, same transaction.
|
|
77
|
+
export const customerStats = j.projection('customer-stats', order, {
|
|
78
|
+
OrderPlaced: (tx, e) =>
|
|
79
|
+
tx
|
|
80
|
+
.insert(customerOrderCounts)
|
|
81
|
+
.values({ customerId: e.data.customerId, placed: 1 })
|
|
82
|
+
.onConflictDoUpdate({
|
|
83
|
+
target: customerOrderCounts.customerId,
|
|
84
|
+
set: { placed: sql`${customerOrderCounts.placed} + 1` },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Async lane: after commit, at least once. The context also has `db`, the root database,
|
|
89
|
+
// and `run`, which runs a command. An effect lists what else it needs and gets it in the context.
|
|
90
|
+
export const orderMail = j.effect(
|
|
91
|
+
'order-mail',
|
|
92
|
+
order,
|
|
93
|
+
{ mailer: need<Mailer>() },
|
|
94
|
+
{
|
|
95
|
+
OrderPaid: (e, { jobId, mailer }) =>
|
|
96
|
+
mailer.send({ template: 'order-paid', orderId: e.entityId, idempotencyKey: jobId }),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
export const tables = journalTables(); // or journalTables(pgSchema('app'))
|
|
101
|
+
|
|
102
|
+
// src/journal.root.ts — the one place that lists handlers.
|
|
103
|
+
export const journal = j.compose({ tables, projections: [customerStats], effects: [orderMail], services: { mailer } });
|
|
104
|
+
|
|
105
|
+
// The request context binds the door once: the database and the signed-in actor.
|
|
106
|
+
export const createContext = (user: User) => ({ user, runCommand: journal.bind(db, { actorId: user.id }) });
|
|
107
|
+
|
|
108
|
+
// A procedure. The schema is reused. A rule that rejects the request throws `CommandError`;
|
|
109
|
+
// `commandErrors()` from `@arki/journal/orpc`, on the builder, answers the client with its code.
|
|
110
|
+
export const pay = protectedProcedure
|
|
111
|
+
.input(payOrder.input.pick({ orderId: true, amount: true }))
|
|
112
|
+
.handler(({ context, input }) => context.runCommand(payOrder, input));
|
|
113
|
+
|
|
114
|
+
// Boot. One worker per process runs the async lane. Root database only, never a transaction.
|
|
115
|
+
// `journal.run(db, ...)` wakes it after each commit that queued a job.
|
|
116
|
+
const worker = journal.worker(db);
|
|
117
|
+
await worker.start();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Commands
|
|
121
|
+
|
|
122
|
+
`j.command(name, inputSchema, handler)` returns a plain value with `name`, `input` (the schema, so a procedure can reuse or `.pick` it) and the handler. Nothing is registered anywhere. The handler gets the parsed input and a context:
|
|
123
|
+
|
|
124
|
+
- `tx`: the command's transaction. Use it for reads, or for writes outside any entity.
|
|
125
|
+
- `meta`: the parsed metadata. Every `write` in the command records it.
|
|
126
|
+
- `write(entity, id, events)`: `journal.write` bound to `tx` and `meta`. `events` is `(row, { tx }) => pending events`, sync or async.
|
|
127
|
+
- `run(child, input)`: run another command in a savepoint of the same transaction, with the same `meta`.
|
|
128
|
+
|
|
129
|
+
`journal.run(source, command, input, meta)` parses `input` with the command's schema (`parseAsync`; a `ZodError` passes through) and `meta` with the app schema, then calls the handler inside `source.transaction(...)`. The root database opens a transaction. A transaction opens a savepoint, so the caller still decides the commit. It returns what the handler returns. Errors pass through unchanged.
|
|
130
|
+
|
|
131
|
+
`journal.bind(source, meta)` is the door. It returns `(command, input) => journal.run(source, command, input, meta)`, so a request context or a job fixes the database and the actor once, and every call site passes only the command and its input. The caller still gives `meta`: the library never picks an actor. `source` follows the rules of `run`: a root database commits and wakes, a transaction joins and leaves the commit to the caller. `meta` is parsed on every call, not once at bind time. A bound runner is then exactly `journal.run`: a default in the meta schema is new for each command, and a bad `meta` rejects the call. The runner has the type `CommandRunner`, the type of a command context's `run`, so it can be passed to code that takes one.
|
|
132
|
+
|
|
133
|
+
Await every `write` and `run` before the handler returns. `run` waits for any operation still running, then fails the command and rolls back, because a transaction must not close with work in flight. If the handler threw, the handler's own error is reported. The low-level `journal.write` inside your own `db.transaction(...)` has no such guard: await every journal call before your transaction callback returns.
|
|
134
|
+
|
|
135
|
+
Inside a command, an uncaught failure anywhere rolls back everything the command did. A child failure the parent catches rolls back only that child. A command may touch no entity, one, or several; it may return any value. Command input is not stored, so it may hold Dates and transforms. Event data and `meta` keep the storage rules below.
|
|
136
|
+
|
|
137
|
+
The types are all inferred: the handler's `input` from the schema, `journal.run`'s `input` as the schema's input type, the return type from the handler, `row`, id and events in `write` from the entity, `tx` from `initJournal.tx<AppTx>()`, `meta` from the meta schema.
|
|
138
|
+
|
|
139
|
+
### Tracing
|
|
140
|
+
|
|
141
|
+
Every recorded event says where it came from. The library stamps three columns; the app never sets them.
|
|
142
|
+
|
|
143
|
+
- `commandId`: the `cmd_` id of the run that wrote the event. A child run gets its own. A bare `journal.write` has none.
|
|
144
|
+
- `causeSeq`: inside an effect's `ctx.run`, the `seq` of the event the job was for. So a reaction points at what it reacts to.
|
|
145
|
+
- `correlationId`: one id for the whole chain. A run from an effect continues the cause event's chain. A root run starts a new chain, unless `journal.run(db, cmd, input, meta, { correlationId })` or `journal.bind(db, meta, { correlationId })` seeds it, for example with the request id. A bare write starts its own chain.
|
|
146
|
+
|
|
147
|
+
The command context exposes `commandId` and `correlationId` for logs. To read a chain: `select * from journal_events where correlation_id = $1 order by seq`. Rows written before these columns existed have `null` in all three.
|
|
148
|
+
|
|
149
|
+
### Idempotent commands
|
|
150
|
+
|
|
151
|
+
A webhook or a retried request can run the same command twice. `journal.run(db, cmd, input, meta, { idempotencyKey })` makes the second run answer like the first and write nothing. The first run records the key, the command name, the parsed input and its result in `journal_receipts`, inside its own transaction, so a receipt exists exactly when the run committed. A later run with the same key and the same input returns the stored result without running the handler; hooks still run and see `ctx.idempotencyKey`. The same key with another input, or with another command, is `CONFLICT`. A concurrent repeat waits for the first to commit, then replays it. A key cannot be reused inside its own run.
|
|
152
|
+
|
|
153
|
+
The result must survive JSON, because the replay returns the stored form; a result with a `Date` or a class instance fails the first run with `UNSTABLE_SCHEMA`. Return plain data: an id, a status, a version. A key from outside is scoped by the app: `${tenant}:${eventId}`, never the bare client value. A bound runner and an effect's `ctx.run` take the key per call: `runAs(cmd, input, { idempotencyKey })`.
|
|
154
|
+
|
|
155
|
+
Receipts hold inputs and results, so they can hold personal data, and `forget` does not touch them. An app that keys personal flows prunes on a schedule: `journal.pruneReceipts(db, { before })`.
|
|
156
|
+
|
|
157
|
+
### Where commands run from
|
|
158
|
+
|
|
159
|
+
The same `journal.run` serves every trigger. `journal.bind(db, meta)` is the same call with `db` and `meta` fixed. Checking who the caller is stays application code; business rules live in the command.
|
|
160
|
+
|
|
161
|
+
| Trigger | Call | Notes |
|
|
162
|
+
| --------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
163
|
+
| Procedure | `context.runCommand(command, input)` | The request context binds once: `journal.bind(db, { actorId: user.id })`. |
|
|
164
|
+
| Job | `journal.run(db, command, payload, job.meta)` | The queue owns retries. |
|
|
165
|
+
| Schedule | `journal.run(db, command, input, { actorId: 'system' })` | A clear system actor; `meta` keeps its schema. |
|
|
166
|
+
| Webhook | `journal.run(db, command, mappedInput, trustedMeta)` | The adapter verifies the signature and maps the payload. |
|
|
167
|
+
| Effect | `ctx.run(command, input, { actorId: 'system' })` | See below. An effect never imports the journal root. |
|
|
168
|
+
| Script | `journal.run(db, command, input, { actorId: 'script:backfill' })` | One call per item; each is its own transaction. |
|
|
169
|
+
| Test | `journal.run(db, command, input, meta)` | No procedure, no HTTP. The same validation and transaction. |
|
|
170
|
+
|
|
171
|
+
An effect that needs to write runs a command with `run` from its context:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// src/features/orders/effects.ts
|
|
175
|
+
export const closeAfterPayment = j.effect('close-after-payment', order, {
|
|
176
|
+
OrderPaid: (e, { run }) => run(closeOrder, { orderId: e.entityId }, { actorId: 'system' }),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// src/journal.root.ts
|
|
180
|
+
export const journal = j.compose({ tables, effects: [closeAfterPayment] });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `run(command, input, meta)` is `journal.run` on the root database that the worker or `drain` was given. It opens its own transaction and commits before it returns.
|
|
184
|
+
- `meta` is required. The library never picks an actor. An app may still wrap `run` in one small helper, so the system actor is written in one place.
|
|
185
|
+
- If the command throws, the effect fails. Nothing of the command is committed. The job is retried with backoff.
|
|
186
|
+
- If the effect fails after `run` returned, the command stays committed and the job runs again. So the command can run twice. Make it safe: check the row and return `[]`, or pass `jobId` in the input and store it as the key. `jobId` is the same on every retry.
|
|
187
|
+
- A command that queues a job wakes the worker. A chain of effects does not wait for the poll.
|
|
188
|
+
|
|
189
|
+
## The low-level write
|
|
190
|
+
|
|
191
|
+
`journal.write(tx, entity, id, { meta, events })` is what a command's `write` calls. Use it directly only when there is no command: a one-off migration step, or a test that needs a specific `meta` per write. `events` is the function that looks at the locked row, checks the rules, and returns the events to append. It runs after the row is locked, inside the write's savepoint, and receives that savepoint as `{ tx }`, so it can read related rows under the lock. It may be async. It only accepts events declared on the entity; the row type, the id type and the event data types are all inferred.
|
|
192
|
+
|
|
193
|
+
`expectedVersion` is the row version the caller saw. If the stored version differs (0 for no row), the write is refused with `CONFLICT` before `events` runs. A command passes it on: `write(order, id, events, { expectedVersion: input.version })`. The client sends the version it rendered; `commandErrors()` turns the refusal into a 409 that asks it to reload. This is how concurrent editors and stale game moves are caught without a hand-written version compare.
|
|
194
|
+
|
|
195
|
+
`write` returns `{ row, events }`: the stored row after the write (`null` after a delete) and the recorded events, each with `seq`, `id`, `entityId`, `version`, `meta`, `recordedAt` the trace `commandId`, `causeSeq`, `correlationId` (see Tracing), and `erasedAt` (see Erasure). An entity can be created again after a delete; its version continues from the last event.
|
|
196
|
+
|
|
197
|
+
### Rows that existed before the journal
|
|
198
|
+
|
|
199
|
+
A row the journal creates gets version 1 from its create event. A row that was there first (stored `version` 0, no events in the log) can still be written to, but its history starts at version 2: the first write treats the row as version 1 and records its event as version 2. Version 1 therefore always means "created from nothing". `replay`, `verify` and `repair` need a history that starts at version 1 and throw `NO_HISTORY` otherwise, because folding a partial history from nothing would pick the wrong reducers and build the wrong row. They also need every version up to the top, and a live row no newer than its log, and throw `HISTORY_GAP` otherwise: an event lost after it was written must be restored, not folded around.
|
|
200
|
+
|
|
201
|
+
To bring such a row in properly, adopt it before its first write. `journal.adopt(tx, entity, id, { meta, events })` takes the row at version 0 with no history, calls `events(row)` for exactly one create event built from the stored row, and checks that the event's `.create` reducer rebuilds the row exactly, column by column. If it does, the event is recorded as version 1 and the row moves to version 1: the history is complete, and `replay`, `verify` and `repair` work from then on. If it does not, `VALUE_MISMATCH` names the column and nothing changes. A row the journal already knows, a missing row, or a legacy row that was already written to is `CONFLICT`. Projections run and effects are queued as for any write, because the event is the app's own. Run it once per legacy row, in a migration or a one-off script, before the app writes to the row.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
await db.transaction(tx =>
|
|
205
|
+
journal.adopt(tx, order, id, {
|
|
206
|
+
meta: { actorId: 'migration' },
|
|
207
|
+
events: row => [OrderPlaced({ customerId: row.customerId, total: row.total })],
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Erasure
|
|
213
|
+
|
|
214
|
+
Personal data written into an event stays in the log until the log forgets it. An event that can hold personal data declares its erased form: `j.event('MessageSent', schema, { erased: data => ({ ...data, content: '' }) })`. The erased form keeps everything that is not personal and must still fit the event's own schema. An event without one holds no personal data and is never touched.
|
|
215
|
+
|
|
216
|
+
`journal.forget(db, entity, id)` rewrites every stored event of that entity that has an erased form to it, sets `erased_at` on those rows, and then repairs the row from the rewritten log, so the row and its history agree. All in one transaction. An erased form the schema rejects is `BAD_DEFINITION` and nothing changes. A history replay cannot rebuild, such as a legacy row that was never adopted, is `NO_HISTORY` and nothing changes: adopt it first. Running it twice is safe; the first erased time is kept. It returns `{ erased, row }`.
|
|
217
|
+
|
|
218
|
+
Projections are not rerun and no job is queued: the app owns its read models and forgets them itself, in the same request. Recorded events carry `erasedAt`, so a projection that is rerun later can tell an erased event from a live one.
|
|
219
|
+
|
|
220
|
+
### A row removed outside the journal
|
|
221
|
+
|
|
222
|
+
If a row is deleted by hand while its log says it exists, the next write that would create it throws `CONFLICT`. Write would apply `.create` where replay applies `.on`, and the two would disagree. Run `repair` to restore the row from the log, then write again. A row the journal deleted is fine: its log ends with the delete, and a create continues the version sequence.
|
|
223
|
+
|
|
224
|
+
### The key column
|
|
225
|
+
|
|
226
|
+
Names are non-empty and well-formed text: at most 64 characters for an entity kind, and 128 for an event type or an effect name. `j.entity`, `j.event` and `j.effect` throw otherwise.
|
|
227
|
+
|
|
228
|
+
The key column is `id` unless the entity names another one by its property name: `j.entity('profile', profiles, { key: 'accountId' })`. The name is checked against the table's columns at the type level and must be one property name, not a union. The column must hold a string, be `notNull`, and be the primary key or unique, either on the column (`.primaryKey()`, `.unique()`) or at table level as a single-column `unique()`, `uniqueIndex()` or `primaryKey()`. A `uniqueIndex` with a `where` does not count: it is unique only inside that subset. `j.entity` checks this and throws otherwise. The id argument of `write`, `replay`, `verify` and `repair`, the key on the `.on` row, and `entityId` on every recorded event all have that column's own type (for example a branded `AccountId`). `journal_events.entity_id` stores the string.
|
|
229
|
+
|
|
230
|
+
The journal compares keys as exact strings, so the key column must not treat two different strings as one key. Four rules:
|
|
231
|
+
|
|
232
|
+
- `text` and `varchar` (with or without a length) are allowed, with the database's default, exact comparison. Do not give them a case-insensitive collation. `varchar(256).$type<Branded>()` is fine.
|
|
233
|
+
- An id is at most 256 characters, or the column's own length if that is smaller, counted in Unicode code points. It must not be empty. `write`, `replay`, `verify` and `repair` refuse any other id with `ID_MISMATCH`, before they touch the database. Nothing is trimmed or cut for you.
|
|
234
|
+
- An id must be well-formed text. An id with an unpaired surrogate (such as `"x\uD800"`) is refused with `ID_MISMATCH`, before the database is touched. The driver would store it as U+FFFD and it would reach the history of another id. Nothing is replaced for you.
|
|
235
|
+
- `uuid` is allowed, but ids must be lower-case and hyphenated. `write`, `replay`, `verify` and `repair` refuse any other spelling with `ID_MISMATCH`, before they touch the database. Nothing is lower-cased for you.
|
|
236
|
+
- Every other type (`citext`, `char(n)`, numbers) is refused when the entity is defined. So is a custom column type (`customType`), even one that reports `text` or `varchar(n)`, because its mapping can change the string.
|
|
237
|
+
|
|
238
|
+
Why: if the database sees two spellings as one key but the log sees two, one entity gets two histories, and `repair` could replay only half. `ReducerValues<Tbl, Key>` omits the key column and `version`.
|
|
239
|
+
|
|
240
|
+
The id you pass must be exactly the key the database stores. Some columns treat two spellings as one key, such as a `uuid` (upper and lower case) or case-insensitive text. The journal logs events under the string you pass, so a second spelling would split one history in two. It refuses any id that differs from the stored key with `ID_MISMATCH`, and writes nothing. For `uuid` keys, pass the lower-case form.
|
|
241
|
+
|
|
242
|
+
### One operation at a time per transaction
|
|
243
|
+
|
|
244
|
+
Savepoints are a stack. Two `write` or `run` calls open on one transaction at the same time would release each other's savepoint, so a caught failure of one could erase what the other wrote. A transaction therefore holds one journal operation at a time: await each `write` and `run` before starting the next on the same transaction. A second one started early waits for the first to settle, then rejects with an error naming the command or entity. A child command runs on its own savepoint, so a `run` inside a `run` is fine, and so are operations on the root database, which get separate transactions.
|
|
245
|
+
|
|
246
|
+
### The app's transaction type
|
|
247
|
+
|
|
248
|
+
`initJournal({ meta })` types the command's `tx`, `write`'s `tx`, `events`' `{ tx }` and projection handlers' `tx` with the structural `DbHandle`. To use the app's own Drizzle transaction type instead, so repositories typed against it fit, declare it once: `initJournal.tx<AppTx>().create({ meta })`. `tx` is a type-only step. `run` and `rerun` then take any database that opens `AppTx` transactions; `drain` and `worker` keep taking the root database. To name the root database type as well, see Effects.
|
|
249
|
+
|
|
250
|
+
### Reducers
|
|
251
|
+
|
|
252
|
+
Events are read-only. A reducer, projection or effect that tries to change `e.data` or `e.meta` gets a `TypeError`. Copy first: `[...e.data.items].sort()`.
|
|
253
|
+
|
|
254
|
+
An `.on` reducer receives the reducer-shaped state (`ReducerRow<Tbl, Key>`: the insert shape plus the key column and `version`) and returns a patch, `Partial<ReducerValues<Tbl, Key>> | null`. The patch is merged onto the state. A key set to `undefined` means unchanged. `events(row, { tx })` still receives the stored `Row<Tbl> | null`.
|
|
255
|
+
|
|
256
|
+
Columns that every `.create` reducer sets are required on the `.on` row. A column one create leaves to its database default, sets on one branch only, or may set to `undefined`, stays optional. The rule: every `.create` comes before the first `.on`. After the first `.on` the builder has no `.create`, and calling one anyway throws, because a later create could leave out a column the `.on` was promised. An `.on` declared before any `.create` sees the plain insert shape.
|
|
257
|
+
|
|
258
|
+
One event may have both a `.create` and an `.on` reducer, for an event that may open an entity or update it (a grant, a flag). The fold picks by state: no row runs the create reducer, a row runs the on reducer. An event with only a create reducer on an existing row is `ROW_EXISTS`; an event with only an on reducer and no row is `NO_ROW`. Declaring the same event twice in the same mode throws at definition time.
|
|
259
|
+
|
|
260
|
+
A reducer must produce each value in the form the database stores. If the database would round or trim it, the write is refused with `VALUE_MISMATCH`. The message names the entity, id and column, and shows both values. For a `numeric(10,2)` column, a reducer that returns `"1.234"` is refused, because the database stores `1.23`. Return `"1.23"` instead. Why: `replay` and `repair` recompute the row from reducer output. Later reducers read the stored row. If the two differ, a correct balance drifts. For a JSON column, a reducer may produce plain objects, full arrays, strings, numbers, booleans and `null`. A `Map`, a `Set` or an array with holes is refused, because JSON storage would lose its contents. A custom column may hold a value object: a class instance with an `equals` method, such as a Temporal `Instant` from `@arki/db/temporal`. It is compared with its `equals`, so a value the column rebuilds exactly passes and a value the column changed is refused. The check covers the columns a reducer set in this write. `repair` makes the same check and leaves the row as it was.
|
|
261
|
+
|
|
262
|
+
Database defaults are not journal state. `write` folds from the stored row, where a default-filled column has its value. `replay` folds from nothing, where that column is `undefined`. That is why such columns are optional in the `.on` row type unless a create sets them. The rule: a reducer must set every column that any reducer later reads. In a `.create` result, a key set to `undefined` is stripped and the default applies. `verify` compares only the columns reducers set.
|
|
263
|
+
|
|
264
|
+
`write` parses every event's data with its schema, even for a hand-built `{ type, data }`. The schema must accept its own stored output unchanged, because replay parses stored data again; otherwise `write` throws `UNSTABLE_SCHEMA`. The same rule applies to `meta`. An optional key holding `undefined` in event data is not stored: JSON drops it, so the stored event, the reducers and replay all see the key as absent. The check allows this, because absent and `undefined` read the same way. Leaving the key out is the same thing.
|
|
265
|
+
|
|
266
|
+
An error about one entity's row or history is a `JournalError` with a `code`. Match on the code, never on the message. The message names the entity kind and id, adds the event type and version when the journal knows them, and ends with the next step.
|
|
267
|
+
|
|
268
|
+
The twelve codes, the usual cause of each and what to do are in one table: [agent guide, Errors](docs/agent-guide.md#errors).
|
|
269
|
+
|
|
270
|
+
Other errors are not `JournalError`. A wrong definition or a misuse is a plain `Error` whose message starts with `journal:`. Input, event data or `meta` that fails its schema is a `ZodError`. An error thrown by a command handler, an `events` function, a reducer or a projection passes through unchanged.
|
|
271
|
+
|
|
272
|
+
### Rejected rules
|
|
273
|
+
|
|
274
|
+
A rule of the app that rejects a request throws `new CommandError(code, message)`. The code is `NOT_FOUND`, `BAD_REQUEST`, `FORBIDDEN` or `CONFLICT`. The message is written for the client. It is not a `JournalError`: a `JournalError` is the library's own failure, a `CommandError` is the app saying no. The command rolls back and the caller gets the same error. `isCommandError(error, code?)` matches it.
|
|
275
|
+
|
|
276
|
+
An oRPC app maps it once, with `@arki/journal/orpc`. `@orpc/server` is an optional peer. The core entry never imports it.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
// src/core/orpc.ts
|
|
280
|
+
import { commandErrors } from '@arki/journal/orpc';
|
|
281
|
+
|
|
282
|
+
// After the app's own error middleware, so a rejected rule is already an `ORPCError` when that one sees it.
|
|
283
|
+
export const publicProcedure = os.use(appErrors).use(commandErrors());
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`commandErrors()` answers a `CommandError` with the `ORPCError` of the same code and message, and a `JournalError` with code `CONFLICT` with a 409 that asks the client to try again. Every other error passes unchanged. `toORPCError(error)` is the same mapping as a function. A host without oRPC writes its own few lines with `isCommandError`. The codes and their HTTP meaning are in the [agent guide](docs/agent-guide.md#rejected-rules-commanderror).
|
|
287
|
+
|
|
288
|
+
Other verbs: `replay(db, entity, id, { throughVersion })` (returns the reducer-shaped row), `verify(db, entity, id)`, `repair(db, entity, id)`, `rerun(db, projection, { afterSeq, batchSize })`, `drain(db, { batchSize, signal })`. `drain` and `worker` take the root database (`RootDb`), never a transaction: effects must run after commit. Lease and retry times use the database clock.
|
|
289
|
+
|
|
290
|
+
### Changing reducers later
|
|
291
|
+
|
|
292
|
+
`replay`, `verify` and `repair` use today's reducers and today's event schemas. They do not remember the code that ran when the event was written.
|
|
293
|
+
|
|
294
|
+
So changing how an existing event is reduced changes what `repair` writes for existing rows. `verify` will report those rows as drifted.
|
|
295
|
+
|
|
296
|
+
For a new meaning, add a new event type. Leave the old reducer as it is.
|
|
297
|
+
|
|
298
|
+
### Changing events later
|
|
299
|
+
|
|
300
|
+
Adding an optional field, or one with a default, is safe. Old rows still parse.
|
|
301
|
+
|
|
302
|
+
For any other change to an event's data, such as a rename, a new required field, or a new structure, keep the old schema and add an upcaster:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
const NoteSetV1 = z.object({ text: z.string() });
|
|
306
|
+
|
|
307
|
+
const NoteSet = j.event('NoteSet', z.object({ note: z.string(), by: z.string() }), {
|
|
308
|
+
upcasters: [{ from: NoteSetV1, up: v1 => ({ note: v1.text, by: 'unknown' }) }],
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Each upcaster lifts data one version up. `upcasters[0]` takes version 1 to 2, the next takes 2 to 3, and so on. The event's version is one more than the number of upcasters. New rows store it in `schema_version`, and old rows keep the version they were written at.
|
|
313
|
+
|
|
314
|
+
Every reader goes through the same decoder: `replay`, `verify`, `repair`, `forget`, `rerun`, async projections, effects and taps. It parses an old row with each step's `from` schema, calls `up`, and parses the result with the current schema. Each `up` gets the typed output of its own `from`. Readers only ever see the current shape. The stored row is never rewritten, except by `forget`, which stores the erased form at the current version.
|
|
315
|
+
|
|
316
|
+
The rules:
|
|
317
|
+
|
|
318
|
+
- Append a step for each change. Never edit or remove a step, because rows at that version still exist.
|
|
319
|
+
- An upcaster is a pure function of the old data. It must not read the clock, the database or the row.
|
|
320
|
+
- A row from a newer release than the reader is `REPLAY_FAILED`, so old code never guesses at a new shape. Deploy readers before the first write of a new version if old and new code run side by side.
|
|
321
|
+
- A row that does not fit its version's schema, or an upcaster that throws, is `REPLAY_FAILED` and names the version.
|
|
322
|
+
|
|
323
|
+
### Async projections
|
|
324
|
+
|
|
325
|
+
A projection runs inline by default: inside the write's transaction, so the read model is exact. A slow or large one can run later instead: `j.projection('leaderboard', game, handlers, { lane: 'async' })`. The write then only wakes the worker. The worker, after every drain pass, gives each async projection one batch: it locks the projection's row in `journal_cursors`, scans the next `batchSize` events in commit order, applies the matching ones on that same transaction, and moves the cursor to the last event scanned. Progress and the read model's writes commit together, so a crash cannot apply a batch twice or skip one. The cursor counts every event, matching or not, so `journal.cursors(db)` reports an honest `lag` in seqs, and lag 0 means caught up. A projection whose batch throws is skipped that pass and tried again next pass; the others go on.
|
|
326
|
+
|
|
327
|
+
`journal.catchUp(db, { batchSize })` runs one batch per async projection by hand, for tests and scripts. `journal.reset(db, projection, async tx => { await tx.delete(table) })` empties the read model and rewinds the cursor in one transaction, with the cursor locked first, so no batch lands in between. To rebuild with no downtime, deploy the projection under a new name into a new table, wait for lag 0, switch reads, then drop the old one. `rerun` is for inline projections only. `forget` locks every async cursor before it rewrites, so a batch in flight cannot commit erased data after it.
|
|
328
|
+
|
|
329
|
+
## Effects
|
|
330
|
+
|
|
331
|
+
An effect runs after the commit, at least once, from a row in `journal_jobs`. A handler gets the event and a context: `(e, { jobId, signal, db, run })`.
|
|
332
|
+
|
|
333
|
+
- `jobId` stays the same across retries. Pass it on as an idempotency key.
|
|
334
|
+
- `signal` is the `signal` given to `drain`.
|
|
335
|
+
- `db` is the root database given to `drain` or `worker`: the same object. Use it to read committed rows. The effect needs no closure for the database.
|
|
336
|
+
- `run` runs a command on that database: `run(command, input, meta)`. See [Where commands run from](#where-commands-run-from).
|
|
337
|
+
|
|
338
|
+
An effect that needs a service (a mailer, an in-process emitter) lists it. The list is the third argument:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
export const orderMail = j.effect(
|
|
342
|
+
'order-mail',
|
|
343
|
+
order,
|
|
344
|
+
{ mailer: need<Mailer>() },
|
|
345
|
+
{
|
|
346
|
+
OrderPaid: (e, { jobId, mailer }) =>
|
|
347
|
+
mailer.send({ template: 'order-paid', orderId: e.entityId, idempotencyKey: jobId }),
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
export const journal = j.compose({ tables, effects: [orderMail], services: { mailer } });
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- `need<Mailer>()` comes from `@arki/journal`. It is a marker: it holds the type and no value.
|
|
355
|
+
- Each handler gets the service in its context, under the name of the need, with its type.
|
|
356
|
+
- The app gives the real services once, in `services`. Every need of every listed effect must be there. A missing one is a compile error, and `compose` throws: `journal: effect "order-mail" needs "mailer", but no service with that name was given; ...`.
|
|
357
|
+
- Two effects that use the same name get the same service.
|
|
358
|
+
- A need cannot be named `jobId`, `signal`, `db` or `run`. `j.effect` throws.
|
|
359
|
+
- An effect that needs nothing has three arguments: `j.effect(name, entity, on)`.
|
|
360
|
+
|
|
361
|
+
`db` has the library's `RootDb` type unless the app names its own database type once: `initJournal.db<AppDb>().create({ meta })`. Then `ctx.db.query.orders` is typed, and `drain` and `worker` need an `AppDb`. They still refuse a transaction. With `.tx<AppTx>()` they also need a database that opens `AppTx` transactions, because effects run commands on it. The app's own Drizzle database does. `.tx<AppTx>()` and `.db<AppDb>()` are both optional type-only steps and chain in either order: `initJournal.tx<AppTx>().db<AppDb>().create({ meta })`.
|
|
362
|
+
|
|
363
|
+
### When a job runs
|
|
364
|
+
|
|
365
|
+
An effect can carry a schedule: `j.effect('receipt', order, handlers).withSchedule({ priority: 10 })`. `priority` (an integer, default 0): among due jobs the higher one is claimed first. It is strict, so a steady stream of high-priority jobs can starve lower ones; use a few levels, not a score. `delaySeconds`: the job is due that long after the write's transaction started (Postgres `now()`), so a long transaction uses part of the delay before commit. `runAt: event => Date`: the job is due at the time the event names, from the app's clock; a time in the past is due at once. Give `delaySeconds` or `runAt`, not both. Retries keep their backoff and their priority. A bad value throws `BAD_DEFINITION` where the schedule is declared.
|
|
366
|
+
|
|
367
|
+
## The worker
|
|
368
|
+
|
|
369
|
+
`journal.worker(db, { pollMs, batchSize })` is a loop around `drain`. When a pass finds no job it sleeps `pollMs` (1000 by default).
|
|
370
|
+
|
|
371
|
+
- A pass claims one job at a time, right before it runs it, up to `batchSize` jobs. So each job's lease (`leaseSeconds`, default 60) covers its own work only, and a slow job cannot let the lease of a later one run out. A job queued during the pass can run in the same pass.
|
|
372
|
+
- While an effect runs, the worker renews its lease every half lease, under its own token. A long effect is safe for as long as its worker is alive. If the renewal finds the lease gone (the worker stalled past a whole lease and another worker reclaimed the job), the effect's `ctx.signal` aborts so it can stop, and its result is not acknowledged: the other worker owns the job now. An effect that does external work should still pass `jobId` on as an idempotency key, because the stall can happen between the work and the ack.
|
|
373
|
+
- `stop()` aborts the running effect's `ctx.signal`, claims no further job, and resolves when that effect returns. An effect that does long work should pass `ctx.signal` on (for example to `fetch`) so shutdown does not wait for it.
|
|
374
|
+
- `drain` and the worker never claim a job whose effect this journal does not have. During a rolling deploy an older worker may still have it. After the deploy, `journal.strandedJobs(db)` lists what is left, counted per effect name. A non-empty list means an effect was renamed or removed while it had jobs: bring it back under its old name until the count is zero, or delete those rows on purpose.
|
|
375
|
+
|
|
376
|
+
A wake ends the sleep, so an effect does not wait for the next poll:
|
|
377
|
+
|
|
378
|
+
- `journal.run(db, ...)` on the root database wakes by itself. The library committed the transaction, so it knows when. It wakes only when the command queued a job.
|
|
379
|
+
- With your own transaction (`journal.run(tx, ...)` or `journal.write(tx, ...)`) nothing wakes. The library cannot see your commit. Call `journal.wake()` after you commit.
|
|
380
|
+
- `journal.wake()` wakes every running worker this journal started in this process. `worker.wake()` wakes one worker. With no running worker a wake does nothing.
|
|
381
|
+
- A wake that arrives while a pass is running is not lost: one more pass runs right after. Many wakes make one extra pass.
|
|
382
|
+
- The wake works inside one process only. Other processes pick jobs up by polling.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
await db.transaction(async tx => {
|
|
386
|
+
await journal.run(tx, payOrder, input, meta);
|
|
387
|
+
await tx.insert(auditLog).values(entry);
|
|
388
|
+
});
|
|
389
|
+
journal.wake(); // after the commit
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## Taps and the wake channel
|
|
393
|
+
|
|
394
|
+
Every commit that recorded events notifies a Postgres channel, `journal.wakeChannel` (`journal_wake`, or `<schema>_journal_wake` for tables in a schema), with the seq range as JSON: `{ "from": 12, "to": 14 }`. Postgres delivers `NOTIFY` at commit, so a listener hears exactly the commits that happened, in every process, including commits of transactions the caller manages, where `journal.wake()` cannot help. The payload is small and bounded; a listener loads what it needs by seq.
|
|
395
|
+
|
|
396
|
+
A worker hears it through `listen`: `journal.worker(db, { listen })`, where `listen` is `onMessage => unlisten`. `LISTEN` needs a dedicated connection. With a `pg` pool, use `listenOnPool(db.$client, journal.wakeChannel)`: it borrows one client for as long as the worker runs, replaces it when the connection drops, and gives it back on `stop()`. With PGlite, `onMessage => client.listen(journal.wakeChannel, onMessage)`. `LISTEN` hears only what is sent after it ran, and a connection can drop, so the worker keeps polling (`pollMs`); the channel makes it prompt, not correct. A listening worker drains at once on every commit.
|
|
397
|
+
|
|
398
|
+
A tap is a handler that fires from that channel: in-process, after commit, lossy. No job row, no retry, no order promised across entities. `j.tap('live-orders', order, { OrderPlaced: (e, { db }) => hub.publish(e) })`, with needs like an effect: `j.tap(name, entity, { hub: need<Hub>() }, handlers)`, given in `compose({ taps, services })`. It fires in every process whose worker listens, for every event in the announced range that it names, in seq order, with the root database and no `run`: a tap may not issue commands, because a lost notification must never mean lost writes. An error in a tap is logged and swallowed; the command already committed. Taps are not wrapped by effect hooks. A journal with taps refuses a worker without `listen`, unless `{ taps: false }` says that worker runs jobs only and another process listens. For a live page, a cache, a metric. For work that must happen, write an effect.
|
|
399
|
+
|
|
400
|
+
## Hooks
|
|
401
|
+
|
|
402
|
+
The one way to watch or stop a run from outside is a hook: a wrapper that receives the context and `next`. Register at boot, before any work.
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
journal.hooks.command.around(async (ctx, next) => {
|
|
406
|
+
// ctx: { name, input, meta, commandId, correlationId }, frozen
|
|
407
|
+
const span = tracer.startSpan(`command ${ctx.name}`, { attributes: { 'journal.correlation_id': ctx.correlationId } });
|
|
408
|
+
try {
|
|
409
|
+
await next();
|
|
410
|
+
} finally {
|
|
411
|
+
span.end();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
journal.hooks.effect.around(async (ctx, next) => {
|
|
415
|
+
// ctx: { name, event, jobId, attempt }, frozen
|
|
416
|
+
log.debug('effect %s attempt %d for %s', ctx.name, ctx.attempt, ctx.event.id);
|
|
417
|
+
await next();
|
|
418
|
+
});
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The rules, all enforced:
|
|
422
|
+
|
|
423
|
+
- Call `next` exactly once. A second call throws. Returning without calling it throws: throw before `next` to stop the run instead.
|
|
424
|
+
- A rejection of `next` is the hook's rejection too. A hook that catches it and returns still fails, with the original error: a rolled-back command cannot look like a success to the caller.
|
|
425
|
+
- The context is frozen, and the result is the handler's own. A hook cannot change the input or the result.
|
|
426
|
+
- Hooks run in registration order, the first one outermost. Command hooks run inside the transaction, around the handler, so a throw rolls the command back cleanly; a child run is wrapped again, with its own `commandId` and the same `correlationId`. Effect hooks run inside the lease, around the handler, so a throw fails the job like a handler failure: it is retried with backoff and recorded on the job row.
|
|
427
|
+
- Registration is frozen once the journal has run a command, a drain or a worker. A later `around` throws `BAD_DEFINITION`. In a Dot app, a plugin that needs the journal attaches in `boot`, which runs before the worker starts.
|
|
428
|
+
|
|
429
|
+
## Operating the jobs
|
|
430
|
+
|
|
431
|
+
`journal.jobs(db, { status, effect, limit })` lists job rows: `pending` (the default) is everything not dead, due, delayed and leased alike; `dead` is what the worker gave up on after 8 attempts, with `lastError`. `journal.retry(db, jobId)` brings a dead job back: attempts and lease cleared, due now, workers woken. `journal.discard(db, jobId)` deletes a dead job. Both refuse a job that is not dead: retrying a live one would run it twice, and deleting a live one would not stop its work, only lose the record of it. `journal.strandedJobs(db)` finds pending jobs whose effect this journal no longer has.
|
|
432
|
+
|
|
433
|
+
## Dot
|
|
434
|
+
|
|
435
|
+
`@arki/journal/dot` connects the journal to an `@arki/dot` app. It exports the plugin `journal(...)` and the feature slice `journalHandlers(...)`. `@arki/dot` is an optional peer. The core entry never imports it.
|
|
436
|
+
|
|
437
|
+
**Not a Dot app.** List the handlers in one `journal.root.ts`. Start and stop the worker yourself.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
// src/journal.root.ts — the one place that lists handlers.
|
|
441
|
+
export const journal = j.compose({ tables, projections: [customerStats], effects: [orderMail], services: { mailer } });
|
|
442
|
+
|
|
443
|
+
// src/server.ts
|
|
444
|
+
const worker = journal.worker(db);
|
|
445
|
+
await worker.start();
|
|
446
|
+
// On shutdown:
|
|
447
|
+
await worker.stop();
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
**Dot app.** Each feature lists its own handlers with `journalHandlers`. The plugin builds the journal from the feature list. There is no root file: `features.ts` is the only list.
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
// src/features/orders/feature.ts
|
|
454
|
+
export const orders = defineFeature('orders', {
|
|
455
|
+
needs: { mailer: service<Mailer>() },
|
|
456
|
+
use: [journalHandlers({ projections: [customerStats], effects: [orderMail] })],
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// src/app.ts
|
|
460
|
+
export const app = defineApp('shop')
|
|
461
|
+
.use(db())
|
|
462
|
+
.use(mailer())
|
|
463
|
+
.useAll(plugs(features))
|
|
464
|
+
.use(journal({ j, tables: journalTables(), features: tokens(features) }));
|
|
465
|
+
|
|
466
|
+
// The gateway binds the door once per request, in its context factory. A procedure calls `context.run`.
|
|
467
|
+
const run = services.journal.bind(services.db, { actorId: user.id });
|
|
468
|
+
await run(payOrder, input);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
- `journal(...)` publishes `services.journal`. It needs the root database under the `db` key. A transaction there is a compile error.
|
|
472
|
+
- It starts one worker on `start` and stops it on `stop`. Options: `worker: false` (no worker in this process), `pollMs`, `batchSize`.
|
|
473
|
+
- `effects` is a list. An effect with needs gets them from the booted services of the feature that lists it, by name. The feature must declare each one in its own `needs`, or it does not compile.
|
|
474
|
+
- Two features may use the same need name for two different services. Each effect gets the service of its own feature.
|
|
475
|
+
- Every effect is an action in the app manifest (`binding: 'journal'`, id `<name>.run`). Projections are never listed: they run inside the write.
|
|
476
|
+
- Two features with the same projection name or the same effect name fail the boot. The message names the duplicate and both features.
|
|
477
|
+
- A feature without `journalHandlers` adds nothing. That is allowed.
|
|
478
|
+
- The feature tokens are needs at run time only. A feature mounted after `journal(...)`, or not mounted, fails at boot and not at compile time.
|
|
479
|
+
- An app that composed its journal itself passes it: `journal({ journal })`.
|
|
480
|
+
- A second journal uses Dot's `rename`: `rename(journal({ journal: reports }), { journal: 'reportsJournal' }, 'reports-journal')`.
|
|
481
|
+
- An effect runs a command with `ctx.run` in both modes. It needs no closure over the journal.
|
|
482
|
+
|
|
483
|
+
## Tables
|
|
484
|
+
|
|
485
|
+
`journalTables()` returns both. The app's migrations create them (`drizzle-kit generate`). The library never creates tables at runtime.
|
|
486
|
+
|
|
487
|
+
`seq` is commit order, not only insert order. Every write takes one advisory transaction lock (`pg_advisory_xact_lock`, namespace `0x4a524e4c`) at its start and holds it until the transaction commits, so a transaction that took a lower `seq` always commits first. A reader that remembers the last `seq` it saw, as `rerun` does with `afterSeq`, therefore misses nothing. The cost: event appends across the whole app commit one transaction at a time, and a transaction that writes events and then does slow work holds every other writer until it commits. Keep writes near the end of a transaction, or keep the transaction short.
|
|
488
|
+
|
|
489
|
+
```text
|
|
490
|
+
journal_events
|
|
491
|
+
seq bigserial primary key -- commit order, used by rerun
|
|
492
|
+
id varchar not null unique -- evt_xxx
|
|
493
|
+
kind varchar(64) not null
|
|
494
|
+
entity_id varchar(256) not null
|
|
495
|
+
version integer not null -- 1, 2, 3 per entity
|
|
496
|
+
type varchar(128) not null
|
|
497
|
+
data jsonb not null
|
|
498
|
+
meta jsonb not null
|
|
499
|
+
recorded_at timestamptz not null default now()
|
|
500
|
+
command_id varchar -- cmd_xxx, the run that wrote it; null for a bare write
|
|
501
|
+
cause_seq bigint -- the event whose effect ran that command; else null
|
|
502
|
+
correlation_id varchar -- one per chain; see Tracing
|
|
503
|
+
erased_at timestamptz -- set by forget; data holds the erased form
|
|
504
|
+
schema_version integer not null default 1 -- the event's version when data was written
|
|
505
|
+
unique (kind, entity_id, version)
|
|
506
|
+
index (correlation_id)
|
|
507
|
+
|
|
508
|
+
journal_receipts
|
|
509
|
+
key varchar(256) primary key -- the app's idempotency key
|
|
510
|
+
command varchar(128) not null
|
|
511
|
+
input jsonb not null -- the parsed input, after JSON
|
|
512
|
+
result jsonb -- { value } when finished; {} for no value; null while running
|
|
513
|
+
command_id varchar not null
|
|
514
|
+
recorded_at timestamptz not null default now()
|
|
515
|
+
|
|
516
|
+
journal_cursors
|
|
517
|
+
name varchar(128) primary key -- the async projection
|
|
518
|
+
seq bigint not null default 0 -- global scan progress
|
|
519
|
+
updated_at timestamptz not null default now()
|
|
520
|
+
|
|
521
|
+
journal_jobs
|
|
522
|
+
id varchar primary key -- job_xxx; pass it on as an idempotency key
|
|
523
|
+
event_seq bigint not null references journal_events(seq)
|
|
524
|
+
effect varchar(128) not null
|
|
525
|
+
priority integer not null default 0 -- from the effect's schedule; higher first
|
|
526
|
+
attempts integer not null default 0
|
|
527
|
+
available_at timestamptz not null default now()
|
|
528
|
+
lease_until timestamptz
|
|
529
|
+
lease_token varchar
|
|
530
|
+
last_error text
|
|
531
|
+
dead_at timestamptz -- set after 8 failed attempts
|
|
532
|
+
unique (event_seq, effect)
|
|
533
|
+
index (priority desc, available_at) where dead_at is null
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
A job row is deleted when its handler succeeds. The table holds only pending and dead work. The event row is the lasting record.
|
|
537
|
+
|
|
538
|
+
An entity table needs a string key column that is primary or unique (`id` by default) and `version integer not null default 0`.
|
|
539
|
+
|
|
540
|
+
## Debugging
|
|
541
|
+
|
|
542
|
+
`DEBUG=arki:journal:*` prints commands, writes, replays, drains and worker ticks.
|