@arki/journal 0.0.0-stage → 0.1.0

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 +39 -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 +459 -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 +41 -0
  119. package/src/types.ts +439 -0
  120. package/src/worker.ts +610 -0
  121. package/src/write.ts +434 -0
package/src/entity.ts ADDED
@@ -0,0 +1,377 @@
1
+ import { getTableColumns, getTableName } from 'drizzle-orm';
2
+ import { getTableConfig, PgColumn } from 'drizzle-orm/pg-core';
3
+
4
+ import type {
5
+ AnyEventDef,
6
+ EntityDef,
7
+ EntityId,
8
+ EntityTable,
9
+ KeyColumn,
10
+ KeyLimit,
11
+ Patch,
12
+ Recorded,
13
+ RecordedOf,
14
+ ReducerRow,
15
+ Reducers,
16
+ ReducerValues,
17
+ Row,
18
+ } from './types.js';
19
+ import { JournalError } from './errors.js';
20
+ import { assertStoredName } from './id.js';
21
+
22
+ /**
23
+ * The keys that every `.create` reducer so far returns with a type that
24
+ * excludes `undefined`. `CreateResults` is the union of their return types, so a key
25
+ * one of them leaves out, or may set to `undefined`, is not in the set. With
26
+ * no `.create` yet, the set is empty.
27
+ *
28
+ * `keyof CreateResults & string` keeps the mapped type from distributing over the
29
+ * union: `keyof (A | B)` is the keys both have, and `CreateResults[P]` is the union
30
+ * of what each branch holds there. A create returning `cond ? { note } : {}`
31
+ * therefore promises nothing.
32
+ */
33
+ export type GuaranteedCreateKeys<CreateResults> = [CreateResults] extends [never]
34
+ ? never
35
+ : { [P in keyof CreateResults & string]: undefined extends CreateResults[P] ? never : P }[keyof CreateResults &
36
+ string];
37
+
38
+ /**
39
+ * The state an `.on` reducer receives: the reducer-shaped row, with the keys
40
+ * every `.create` reducer sets made required. A patch cannot unset a key and
41
+ * `undefined` values are stripped, so a key a create sets stays set.
42
+ */
43
+ export type OnReducerRow<Tbl extends EntityTable, Key extends string, CreateResults> = ReducerRow<Tbl, Key> &
44
+ Required<Pick<ReducerValues<Tbl, Key>, GuaranteedCreateKeys<CreateResults> & keyof ReducerValues<Tbl, Key>>>;
45
+
46
+ /**
47
+ * An entity under construction. Each `.create` / `.on` adds the event to the
48
+ * union the entity accepts, so `events` cannot return an undeclared event.
49
+ * `CreateResults` collects what the `.create` reducers return; see `OnReducerRow`.
50
+ *
51
+ * The rule: every `.create` comes before the first `.on`. After the first
52
+ * `.on` the builder is `OnOnlyEntityBuilder`, which has no `.create`: a later
53
+ * create could leave out a column that `.on` was promised.
54
+ */
55
+ export type EntityBuilder<
56
+ Kind extends string,
57
+ Tbl extends EntityTable,
58
+ EventDefs extends AnyEventDef,
59
+ Meta,
60
+ Key extends string = 'id',
61
+ CreateResults = never,
62
+ > = OnOnlyEntityBuilder<Kind, Tbl, EventDefs, Meta, Key, CreateResults> & {
63
+ /** A create reducer receives no row. It builds the first row from the event. */
64
+ create<AddedEvent extends AnyEventDef, CreateResult extends ReducerValues<Tbl, Key>>(
65
+ event: AddedEvent,
66
+ reduce: (event: RecordedOf<AddedEvent, Meta, EntityId<Tbl, Key>>) => CreateResult,
67
+ ): EntityBuilder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, CreateResults | CreateResult>;
68
+ };
69
+
70
+ /** An entity after its first `.on`: only more `.on` reducers may follow. */
71
+ export type OnOnlyEntityBuilder<
72
+ Kind extends string,
73
+ Tbl extends EntityTable,
74
+ EventDefs extends AnyEventDef,
75
+ Meta,
76
+ Key extends string = 'id',
77
+ CreateResults = never,
78
+ > = EntityDef<Kind, Tbl, EventDefs, Meta, Key> & {
79
+ /**
80
+ * An `.on` reducer always receives the reducer-shaped state: the insert shape plus
81
+ * the key column and `version`. Columns with database defaults may be absent unless
82
+ * every `.create` reducer sets them. It returns a patch that is merged onto the
83
+ * state. Returning `null` deletes the row; the events stay.
84
+ */
85
+ on<AddedEvent extends AnyEventDef>(
86
+ event: AddedEvent,
87
+ reduce: (
88
+ row: OnReducerRow<Tbl, Key, CreateResults>,
89
+ event: RecordedOf<AddedEvent, Meta, EntityId<Tbl, Key>>,
90
+ ) => Patch<Tbl, Key> | null,
91
+ ): OnOnlyEntityBuilder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, CreateResults>;
92
+ };
93
+
94
+ /** The two facts `KeyColumn` states: the column holds a string and is not null. */
95
+ function isKeyColumn(column: PgColumn): column is KeyColumn {
96
+ const [dataType] = column.dataType.split(' ');
97
+ return dataType === 'string' && column.notNull;
98
+ }
99
+
100
+ /**
101
+ * The supported key types: `text`, `varchar`, `varchar(n)` and `uuid`. The first three compare
102
+ * as exact strings. A `uuid` ignores case, so its ids must also use the canonical spelling
103
+ * (lower-case, hyphenated); `assertExactId` checks that on every call.
104
+ */
105
+ const EXACT_KEY_TYPE = /^(?:text|uuid|varchar(?:\s*\(\s*\d+\s*\))?)$/;
106
+
107
+ /** A column's SQL type in comparable form: trimmed and lower-case. */
108
+ function sqlTypeOf(column: PgColumn): string {
109
+ return column.getSQLType().trim().toLowerCase();
110
+ }
111
+
112
+ /** The journal's own limit: `journal_events.entity_id` holds 256 characters. */
113
+ const LOG_KEY_LIMIT = 256;
114
+
115
+ /**
116
+ * The longest id the key may hold, in code points: `varchar(n)` allows `min(n, 256)`, `text` and
117
+ * `varchar` allow the log's 256, and a `uuid` is fixed at 36.
118
+ */
119
+ function keyLimitOf(column: PgColumn): KeyLimit {
120
+ const sqlType = sqlTypeOf(column);
121
+ if (sqlType === 'uuid') return { max: 36, source: 'column' };
122
+ const length = /\(\s*(\d+)\s*\)/.exec(sqlType)?.[1];
123
+ if (length !== undefined && Number(length) <= LOG_KEY_LIMIT) return { max: Number(length), source: 'column' };
124
+ return { max: LOG_KEY_LIMIT, source: 'journal' };
125
+ }
126
+
127
+ /**
128
+ * Whether `column` identifies one row on its own: it is the primary key, or
129
+ * carries a unique constraint, or is the only column of a table-level primary
130
+ * key, unique constraint or unique index. Table-level constraints and indexes
131
+ * hold their own column objects (an index wraps them), so they are matched by
132
+ * SQL name; an SQL expression in an index has no name and never matches.
133
+ */
134
+ function isUniqueOn(table: EntityTable, column: PgColumn): boolean {
135
+ if (column.primary || column.isUnique) return true;
136
+ const only = (columns: readonly unknown[]): boolean => {
137
+ const [first] = columns;
138
+ return (
139
+ columns.length === 1 &&
140
+ typeof first === 'object' &&
141
+ first !== null &&
142
+ 'name' in first &&
143
+ first.name === column.name
144
+ );
145
+ };
146
+ const config = getTableConfig(table);
147
+ return (
148
+ config.primaryKeys.some(pk => only(pk.columns)) ||
149
+ config.uniqueConstraints.some(constraint => only(constraint.columns)) ||
150
+ // A unique index with a `where` is unique only inside that subset, so it is no key.
151
+ config.indexes.some(index => index.config.unique && index.config.where === undefined && only(index.config.columns))
152
+ );
153
+ }
154
+
155
+ /** The column `table` holds under `key`, checked to be a usable entity key. */
156
+ function keyColumnOf(kind: string, table: EntityTable, key: string): KeyColumn {
157
+ const columns: Record<string, unknown> = getTableColumns(table);
158
+ const column = columns[key];
159
+ if (!(column instanceof PgColumn)) {
160
+ throw new JournalError(
161
+ 'BAD_DEFINITION',
162
+ 'entity',
163
+ kind,
164
+ `journal: entity "${kind}": table "${getTableName(table)}" has no column "${key}"`,
165
+ );
166
+ }
167
+ const [dataType] = column.dataType.split(' ');
168
+ const sqlType = sqlTypeOf(column);
169
+ if (dataType !== 'custom' && !isKeyColumn(column)) {
170
+ throw new JournalError(
171
+ 'BAD_DEFINITION',
172
+ 'entity',
173
+ kind,
174
+ dataType === 'string'
175
+ ? `journal: entity "${kind}": key column "${key}" must be not null`
176
+ : `journal: entity "${kind}": key column "${key}" must hold a string, it holds ${column.dataType}`,
177
+ );
178
+ }
179
+ if (!EXACT_KEY_TYPE.test(sqlType)) {
180
+ throw new JournalError(
181
+ 'BAD_DEFINITION',
182
+ 'entity',
183
+ kind,
184
+ `journal: entity "${kind}": key column "${key}" has SQL type "${sqlType}"; the key must be text, varchar or uuid, because other types can treat two different strings as the same key`,
185
+ );
186
+ }
187
+ if (!isKeyColumn(column)) {
188
+ throw new JournalError(
189
+ 'BAD_DEFINITION',
190
+ 'entity',
191
+ kind,
192
+ `journal: entity "${kind}": key column "${key}" is a custom column type; a key must be a plain text, varchar or uuid column, because a custom type's mapping can change the string`,
193
+ );
194
+ }
195
+ if (!isUniqueOn(table, column)) {
196
+ throw new JournalError(
197
+ 'BAD_DEFINITION',
198
+ 'entity',
199
+ kind,
200
+ `journal: entity "${kind}": key column "${key}" must be the primary key or carry a unique constraint`,
201
+ );
202
+ }
203
+ return column;
204
+ }
205
+
206
+ export function defineEntity<Kind extends string, Tbl extends EntityTable, Meta, Key extends string>(
207
+ kind: Kind,
208
+ table: Tbl,
209
+ key: Key,
210
+ ): EntityBuilder<Kind, Tbl, never, Meta, Key> {
211
+ assertStoredName('entity kind', kind);
212
+ const keyColumn = keyColumnOf(kind, table, key);
213
+ const uuidKey = sqlTypeOf(keyColumn) === 'uuid';
214
+ const keyLimit = keyLimitOf(keyColumn);
215
+ return builder<Kind, Tbl, never, Meta, Key, never>(
216
+ kind,
217
+ table,
218
+ key,
219
+ keyColumn,
220
+ uuidKey,
221
+ keyLimit,
222
+ new Map(),
223
+ new Map(),
224
+ null,
225
+ );
226
+ }
227
+
228
+ function builder<
229
+ Kind extends string,
230
+ Tbl extends EntityTable,
231
+ EventDefs extends AnyEventDef,
232
+ Meta,
233
+ Key extends string,
234
+ CreateResults,
235
+ >(
236
+ kind: Kind,
237
+ table: Tbl,
238
+ key: Key,
239
+ keyColumn: KeyColumn,
240
+ uuidKey: boolean,
241
+ keyLimit: KeyLimit,
242
+ events: ReadonlyMap<string, AnyEventDef>,
243
+ reducers: ReadonlyMap<string, Reducers<Tbl, Meta, Key>>,
244
+ /** The first `.on` event type, once one is declared. `.create` is refused after it. */
245
+ firstOn: string | null,
246
+ ): EntityBuilder<Kind, Tbl, EventDefs, Meta, Key, CreateResults> {
247
+ const declare = <AddedEvent extends AnyEventDef, Next>(
248
+ event: AddedEvent,
249
+ next: Reducers<Tbl, Meta, Key>,
250
+ sealedBy: string | null,
251
+ ): EntityBuilder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, Next> =>
252
+ builder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, Next>(
253
+ kind,
254
+ table,
255
+ key,
256
+ keyColumn,
257
+ uuidKey,
258
+ keyLimit,
259
+ new Map([...events, [event.type, event]]),
260
+ new Map([...reducers, [event.type, next]]),
261
+ sealedBy,
262
+ );
263
+ const create = <AddedEvent extends AnyEventDef, CreateResult extends ReducerValues<Tbl, Key>>(
264
+ event: AddedEvent,
265
+ reduce: (event: RecordedOf<AddedEvent, Meta, EntityId<Tbl, Key>>) => CreateResult,
266
+ ): EntityBuilder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, CreateResults | CreateResult> => {
267
+ if (firstOn !== null) {
268
+ throw new JournalError(
269
+ 'BAD_DEFINITION',
270
+ 'entity',
271
+ kind,
272
+ `journal: entity "${kind}": declare .create(${event.type}) before any .on; .on(${firstOn}) came first`,
273
+ );
274
+ }
275
+ const current = reducers.get(event.type) ?? {};
276
+ if (current.create !== undefined) {
277
+ throw new JournalError(
278
+ 'BAD_DEFINITION',
279
+ 'entity',
280
+ kind,
281
+ `journal: entity "${kind}" already declares a create reducer for "${event.type}"`,
282
+ );
283
+ }
284
+ return declare<AddedEvent, CreateResults | CreateResult>(event, { ...current, create: reduce }, null);
285
+ };
286
+ const on = <AddedEvent extends AnyEventDef>(
287
+ event: AddedEvent,
288
+ reduce: (
289
+ row: OnReducerRow<Tbl, Key, CreateResults>,
290
+ event: RecordedOf<AddedEvent, Meta, EntityId<Tbl, Key>>,
291
+ ) => Patch<Tbl, Key> | null,
292
+ ): OnOnlyEntityBuilder<Kind, Tbl, EventDefs | AddedEvent, Meta, Key, CreateResults> => {
293
+ const current = reducers.get(event.type) ?? {};
294
+ if (current.on !== undefined) {
295
+ throw new JournalError(
296
+ 'BAD_DEFINITION',
297
+ 'entity',
298
+ kind,
299
+ `journal: entity "${kind}" already declares an on reducer for "${event.type}"`,
300
+ );
301
+ }
302
+ return declare<AddedEvent, CreateResults>(event, { ...current, on: reduce }, firstOn ?? event.type);
303
+ };
304
+ return { kind, table, key, keyColumn, uuidKey, keyLimit, events, reducers, create, on };
305
+ }
306
+
307
+ /**
308
+ * A copy of `value` without the own keys that hold `undefined`. In a patch,
309
+ * `undefined` means "unchanged". In a create result it means "not set": the
310
+ * database default applies, and the key is not part of the journal's state.
311
+ */
312
+ function definedOnly<T extends object>(value: T): T {
313
+ const out = { ...value };
314
+ for (const key in out) {
315
+ if (Object.hasOwn(out, key) && out[key] === undefined) delete out[key];
316
+ }
317
+ return out;
318
+ }
319
+
320
+ function hasKey<T extends object, K extends string>(value: T, key: K): value is T & Record<K, unknown> {
321
+ return key in value;
322
+ }
323
+
324
+ /**
325
+ * `{ [key]: id }` typed by the key. TypeScript types a computed key as an
326
+ * index signature; the guard above narrows it back to the one key.
327
+ */
328
+ function keyed<K extends string, Id>(key: K, id: Id): Record<K, Id> {
329
+ const out = { [key]: id };
330
+ if (!hasKey(out, key)) throw new Error(`journal: key "${key}" was not set`);
331
+ return out;
332
+ }
333
+
334
+ /** The reducer-shaped row: a state plus the key column and `version`. */
335
+ export function withEntityIdentity<Tbl extends EntityTable, Key extends string>(
336
+ entity: EntityDef<string, Tbl, AnyEventDef, unknown, Key>,
337
+ state: Row<Tbl> | ReducerValues<Tbl, Key>,
338
+ id: EntityId<Tbl, Key>,
339
+ version: number,
340
+ ): ReducerRow<Tbl, Key> {
341
+ return { ...state, ...keyed(entity.key, id), version };
342
+ }
343
+
344
+ /**
345
+ * Fold one event onto the in-memory state. Shared by write and replay, so
346
+ * both produce the same value for every column a reducer set. The caller has
347
+ * checked that a reducer exists for the event in this state. `reducerColumns`, when
348
+ * given, collects the columns the reducers set: a create starts it afresh, a
349
+ * patch adds its keys, a delete empties it.
350
+ */
351
+ export function reduceOne<Tbl extends EntityTable, Meta, Key extends string>(
352
+ entity: EntityDef<string, Tbl, AnyEventDef, Meta, Key>,
353
+ state: Row<Tbl> | ReducerValues<Tbl, Key> | null,
354
+ id: EntityId<Tbl, Key>,
355
+ event: Recorded<string, unknown, Meta>,
356
+ reducerColumns?: Set<string>,
357
+ ): ReducerValues<Tbl, Key> | null {
358
+ const reducers = entity.reducers.get(event.type);
359
+ if (state === null) {
360
+ if (reducers?.create === undefined)
361
+ throw new Error(`journal: entity "${entity.kind}" has no create reducer for "${event.type}"`);
362
+ const created = definedOnly(reducers.create(event));
363
+ reducerColumns?.clear();
364
+ for (const key of Object.keys(created)) reducerColumns?.add(key);
365
+ return created;
366
+ }
367
+ if (reducers?.on === undefined)
368
+ throw new Error(`journal: entity "${entity.kind}" has no on reducer for "${event.type}"`);
369
+ const patch = reducers.on(withEntityIdentity(entity, state, id, event.version - 1), event);
370
+ if (patch === null) {
371
+ reducerColumns?.clear();
372
+ return null;
373
+ }
374
+ const defined = definedOnly(patch);
375
+ for (const key of Object.keys(defined)) reducerColumns?.add(key);
376
+ return { ...state, ...defined };
377
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,134 @@
1
+ export type JournalErrorCode =
2
+ | 'CONFLICT'
3
+ | 'NO_ROW'
4
+ | 'ROW_EXISTS'
5
+ | 'UNKNOWN_EVENT'
6
+ | 'VERSION_MISMATCH'
7
+ | 'NO_HISTORY'
8
+ | 'HISTORY_GAP'
9
+ | 'REPLAY_FAILED'
10
+ | 'UNSTABLE_SCHEMA'
11
+ | 'ID_MISMATCH'
12
+ | 'VALUE_MISMATCH'
13
+ | 'BAD_DEFINITION';
14
+
15
+ /** Per code: what happened, and what to do next. The throw site adds the detail (event type, version, column). */
16
+ const MESSAGES: Record<JournalErrorCode, { readonly what: string; readonly next: string }> = {
17
+ CONFLICT: {
18
+ what: 'the row and its event history cannot accept this write',
19
+ next: 'If another writer appended first, run the command again on the current state. If the row and the history disagree, check with `verify`, and use `repair` only when the history is complete',
20
+ },
21
+ NO_ROW: {
22
+ what: 'no row exists, and this event has no `.create` reducer',
23
+ next: 'Create the entity first, or declare `.create` for this event on the entity',
24
+ },
25
+ ROW_EXISTS: {
26
+ what: 'a row exists, and this event has no `.on` reducer',
27
+ next: 'Write an update event instead, or declare `.on` for this event on the entity',
28
+ },
29
+ UNKNOWN_EVENT: {
30
+ what: 'the entity does not declare this event',
31
+ next: 'Add `.create(Event, ...)` or `.on(Event, ...)` for it on this entity, or write it to the entity that declares it',
32
+ },
33
+ VERSION_MISMATCH: {
34
+ what: 'no row matched the expected version when saving',
35
+ next: 'The row version changed, or the row was removed, after this write locked it. Look for a statement that does this outside the journal (for example a direct update or delete inside `events`), then run the command again',
36
+ },
37
+ NO_HISTORY: {
38
+ what: 'a complete history that starts at version 1 is not available',
39
+ next: 'Replay and repair cannot rebuild this row. Check whether the row existed before the journal, or restore the missing events',
40
+ },
41
+ HISTORY_GAP: {
42
+ what: 'the log has a hole, or the row is newer than a log that says it is alive',
43
+ next: 'An event was lost after it was written. Replay and repair refuse to build a row the real history never produced. Restore the missing event from a backup, or accept the row as it is',
44
+ },
45
+ REPLAY_FAILED: {
46
+ what: 'a stored event cannot be decoded or applied',
47
+ next: 'Check the event schema and the reducers against the stored history',
48
+ },
49
+ UNSTABLE_SCHEMA: {
50
+ what: 'the value changes or fails across JSON storage and a second parse',
51
+ next: 'Return JSON-compatible data that the schema accepts unchanged, because replay parses stored data again',
52
+ },
53
+ ID_MISMATCH: {
54
+ what: 'the entity id is not valid, or differs from its stored spelling',
55
+ next: 'Use a non-empty, well-formed id within the limit, spelled exactly as stored. A uuid must be lower-case and hyphenated',
56
+ },
57
+ VALUE_MISMATCH: {
58
+ what: 'the database stored a different value than the reducer produced',
59
+ next: 'The write was rolled back. Make the reducer produce the value in the form the column stores',
60
+ },
61
+ BAD_DEFINITION: {
62
+ what: 'a declaration is wrong',
63
+ next: 'Fix the declaration named in the detail. It is thrown when the module that declares it loads, so no data was touched',
64
+ },
65
+ };
66
+
67
+ /**
68
+ * An error about one entity's row or history: a write, replay, verify or repair
69
+ * that the journal refuses. Match on `code`, never on the message.
70
+ *
71
+ * Not every error the journal lets out is a `JournalError`:
72
+ * - A wrong definition is a `JournalError` with code `BAD_DEFINITION`, thrown when the
73
+ * declaration runs (a bad name, a bad key column, a duplicate reducer or handler name).
74
+ * - A misuse at run time is a plain `Error` whose message starts with `journal:` (two
75
+ * operations at once on one transaction, `drain` inside a transaction).
76
+ * - Command input, event data or `meta` that fails its schema is a `ZodError`.
77
+ * - An error thrown by a command handler, an `events` function, a reducer or a
78
+ * projection passes through unchanged. A rejected business rule is one of
79
+ * these: a `CommandError`, below.
80
+ * An error thrown by an effect is caught by `drain` and stored on the job.
81
+ */
82
+ export class JournalError extends Error {
83
+ readonly code: JournalErrorCode;
84
+ readonly kind: string;
85
+ readonly entityId: string;
86
+
87
+ /** The message reads: `CODE: what happened (kind id): detail. What to do next.` */
88
+ constructor(code: JournalErrorCode, kind: string, entityId: string, detail?: string, options?: ErrorOptions) {
89
+ const { what, next } = MESSAGES[code];
90
+ const where = `${code}: ${what} (${kind} ${entityId})`;
91
+ const happened = detail === undefined ? where : `${where}: ${detail}`;
92
+ super(`${happened}. ${next}.`, options);
93
+ this.name = 'JournalError';
94
+ this.code = code;
95
+ this.kind = kind;
96
+ this.entityId = entityId;
97
+ }
98
+ }
99
+
100
+ export function isJournalError(error: unknown, code?: JournalErrorCode): error is JournalError {
101
+ return error instanceof JournalError && (code === undefined || error.code === code);
102
+ }
103
+
104
+ /** The kind of answer a rejected request gets. The names are the usual HTTP ones: 404, 400, 403, 409. */
105
+ export type CommandErrorCode = 'NOT_FOUND' | 'BAD_REQUEST' | 'FORBIDDEN' | 'CONFLICT';
106
+
107
+ /**
108
+ * A business rule rejected the request. App code throws it: a command handler,
109
+ * or the `events` function of a `write`. The code names the kind of answer and
110
+ * the message is written for the client.
111
+ *
112
+ * It is NOT a `JournalError`. A `JournalError` is the library's own failure: a
113
+ * row or a history it refuses. A `CommandError` is the app's rule saying no.
114
+ * The library never throws one and never changes one: like every error thrown
115
+ * by app code, it leaves `journal.run` as the same instance, after the
116
+ * command's transaction rolled back.
117
+ *
118
+ * Nothing here knows HTTP. The host maps the code to its own transport:
119
+ * `@arki/journal/orpc` does it for oRPC, and any other host matches on the
120
+ * code with `isCommandError`.
121
+ */
122
+ export class CommandError extends Error {
123
+ readonly code: CommandErrorCode;
124
+
125
+ constructor(code: CommandErrorCode, message: string) {
126
+ super(message);
127
+ this.name = 'CommandError';
128
+ this.code = code;
129
+ }
130
+ }
131
+
132
+ export function isCommandError(error: unknown, code?: CommandErrorCode): error is CommandError {
133
+ return error instanceof CommandError && (code === undefined || error.code === code);
134
+ }
package/src/event.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { z } from 'zod';
2
+
3
+ import type { EventDef, EventOptions, Pending, Upcaster } from './types.js';
4
+ import { assertStoredName } from './id.js';
5
+
6
+ /**
7
+ * Declare an event. Calling the result validates the data and returns a pending event.
8
+ * `opts.erased` gives the event's erased form for `forget`; see the README, Erasure.
9
+ * `opts.upcasters` lifts data stored under older schemas; see the README, Changing events later.
10
+ */
11
+ export function defineEvent<T extends string, S extends z.ZodType, const U extends readonly z.ZodType[] = readonly []>(
12
+ type: T,
13
+ schema: S,
14
+ opts?: EventOptions<S, U>,
15
+ ): EventDef<T, S> {
16
+ assertStoredName('event type', type);
17
+ const upcasters: readonly Upcaster[] = opts?.upcasters ?? [];
18
+ const make = (data: z.input<S>): Pending<T, z.output<S>> => ({ type, data: schema.parse(data) });
19
+ return Object.assign(make, {
20
+ type,
21
+ schema,
22
+ version: upcasters.length + 1,
23
+ upcasters,
24
+ ...(opts?.erased === undefined ? {} : { erased: opts.erased }),
25
+ });
26
+ }
package/src/fold.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The fold that write and replay share: apply recorded events to a row in memory.
3
+ * Also the two lookups by event type. Pure functions: no database access, no state.
4
+ * May import `entity` and leaf files. Must not import `journal`, `api`, `write`,
5
+ * `command`, `history`, `storage` or `worker`.
6
+ */
7
+ import type {
8
+ AnyEntity,
9
+ AnyEventDef,
10
+ EntityDef,
11
+ EntityId,
12
+ EntityTable,
13
+ Recorded,
14
+ RecordedOf,
15
+ ReducerRow,
16
+ ReducerValues,
17
+ Row,
18
+ } from './types.js';
19
+ import { reduceOne, withEntityIdentity } from './entity.js';
20
+ import { JournalError } from './errors.js';
21
+
22
+ type RecordedEvent<MetaOut> = Recorded<string, unknown, MetaOut>;
23
+
24
+ export const findEventDefinition = <MetaOut>(
25
+ entities: readonly AnyEntity<MetaOut>[],
26
+ kind: string,
27
+ type: string,
28
+ ): AnyEventDef | undefined => entities.find(entity => entity.kind === kind)?.events.get(type);
29
+
30
+ /**
31
+ * Fold events onto a starting state. A create event on a row, or a non-create
32
+ * event with no row, is `ROW_EXISTS` / `NO_ROW` during a write and
33
+ * `REPLAY_FAILED` when reading the log back. `recreated` is true when the state
34
+ * passed through `null` and ended non-null: the row is a new incarnation.
35
+ */
36
+ export const fold = <Tbl extends EntityTable, Key extends string, MetaOut>(
37
+ entity: EntityDef<string, Tbl, AnyEventDef, MetaOut, Key>,
38
+ start: Row<Tbl> | null,
39
+ id: EntityId<Tbl, Key>,
40
+ history: readonly RecordedEvent<MetaOut>[],
41
+ mode: 'write' | 'replay',
42
+ ): {
43
+ readonly row: ReducerRow<Tbl, Key> | null;
44
+ readonly recreated: boolean;
45
+ /** The columns the reducers set in this fold (since the last create). */
46
+ readonly reducerColumns: ReadonlySet<string>;
47
+ } => {
48
+ let state: Row<Tbl> | ReducerValues<Tbl, Key> | null = start;
49
+ let deleted = false;
50
+ const reducerColumns = new Set<string>();
51
+ for (const event of history) {
52
+ const reducers = entity.reducers.get(event.type);
53
+ // A write names the exact refusal. A replay reports each one as REPLAY_FAILED and says why.
54
+ const at = `event "${event.type}" at version ${event.version}`;
55
+ if (reducers === undefined) {
56
+ throw mode === 'write'
57
+ ? new JournalError('UNKNOWN_EVENT', entity.kind, id, at)
58
+ : new JournalError('REPLAY_FAILED', entity.kind, id, `${at} has no reducer on this entity`);
59
+ }
60
+ if (state !== null && reducers.on === undefined) {
61
+ throw mode === 'write'
62
+ ? new JournalError('ROW_EXISTS', entity.kind, id, at)
63
+ : new JournalError(
64
+ 'REPLAY_FAILED',
65
+ entity.kind,
66
+ id,
67
+ `${at} has no .on reducer, but the history has a row at that point`,
68
+ );
69
+ }
70
+ if (state === null && reducers.create === undefined) {
71
+ throw mode === 'write'
72
+ ? new JournalError('NO_ROW', entity.kind, id, at)
73
+ : new JournalError(
74
+ 'REPLAY_FAILED',
75
+ entity.kind,
76
+ id,
77
+ `${at} has no .create reducer, but the history has no row at that point`,
78
+ );
79
+ }
80
+ state = reduceOne(entity, state, id, event, reducerColumns);
81
+ if (state === null) deleted = true;
82
+ }
83
+ if (state === null) return { row: null, recreated: false, reducerColumns };
84
+ const last = history.at(-1);
85
+ const version = last === undefined ? (start === null ? 0 : start.version) : last.version;
86
+ return { row: withEntityIdentity(entity, state, id, version), recreated: deleted, reducerColumns };
87
+ };
88
+
89
+ /**
90
+ * Narrows a decoded event to the entity's own event union. It is a type guard, not
91
+ * a filter for bad data: `decodeStoredEvent` has already thrown for a type the
92
+ * entity does not declare. Do not use it to skip stored events that fail to decode.
93
+ */
94
+ export const hasDeclaredEventType = <E extends AnyEventDef, Id extends string, MetaOut>(
95
+ entity: EntityDef<string, EntityTable, E, MetaOut, string>,
96
+ event: Recorded<string, unknown, MetaOut, Id>,
97
+ ): event is RecordedOf<E, MetaOut, Id> => entity.events.has(event.type);