@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/pg.ts ADDED
@@ -0,0 +1,107 @@
1
+ import type { PgSchema, PgTableFn } from 'drizzle-orm/pg-core';
2
+ import { sql } from 'drizzle-orm';
3
+ import {
4
+ bigint,
5
+ bigserial,
6
+ index,
7
+ integer,
8
+ jsonb,
9
+ pgTable,
10
+ text,
11
+ timestamp,
12
+ unique,
13
+ varchar,
14
+ } from 'drizzle-orm/pg-core';
15
+
16
+ /**
17
+ * The two journal tables. Call this once in the app's schema module so
18
+ * `drizzle-kit generate` creates them. The library never creates tables at
19
+ * runtime.
20
+ */
21
+ export function journalTables(schema?: PgSchema) {
22
+ const table: PgTableFn<string | undefined> = schema === undefined ? pgTable : schema.table;
23
+
24
+ const events = table(
25
+ 'journal_events',
26
+ {
27
+ /** Global order. `rerun` walks the log by this. */
28
+ seq: bigserial('seq', { mode: 'number' }).primaryKey(),
29
+ /** `evt_xxx`. */
30
+ id: varchar('id').notNull().unique(),
31
+ kind: varchar('kind', { length: 64 }).notNull(),
32
+ entityId: varchar('entity_id', { length: 256 }).notNull(),
33
+ /** 1, 2, 3 per entity. */
34
+ version: integer('version').notNull(),
35
+ type: varchar('type', { length: 128 }).notNull(),
36
+ data: jsonb('data').notNull(),
37
+ meta: jsonb('meta').notNull(),
38
+ recordedAt: timestamp('recorded_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
39
+ // Stamped by the library, never by the app. Null on rows written before these columns existed.
40
+ /** The `cmd_` id of the command run that wrote this event. Null for a bare `journal.write`. */
41
+ commandId: varchar('command_id'),
42
+ /** Inside an effect's `ctx.run`: the seq of the event the job was for. Else null. */
43
+ causeSeq: bigint('cause_seq', { mode: 'number' }),
44
+ /** Shared by every event of one chain: inherited from the cause event or the parent run, else new per root run. */
45
+ correlationId: varchar('correlation_id'),
46
+ /** Set by `forget` when `data` was rewritten to the event's erased form. */
47
+ erasedAt: timestamp('erased_at', { withTimezone: true, mode: 'date' }),
48
+ /** The event's schema version when `data` was written. Older rows are upcast on read. */
49
+ schemaVersion: integer('schema_version').notNull().default(1),
50
+ },
51
+ t => [
52
+ unique('journal_events_kind_entity_id_version_unique').on(t.kind, t.entityId, t.version),
53
+ index('journal_events_correlation_id_idx').on(t.correlationId),
54
+ ],
55
+ );
56
+
57
+ const jobs = table(
58
+ 'journal_jobs',
59
+ {
60
+ /** `job_xxx`. Pass it on as an idempotency key. */
61
+ id: varchar('id').primaryKey(),
62
+ eventSeq: bigint('event_seq', { mode: 'number' })
63
+ .notNull()
64
+ .references(() => events.seq),
65
+ effect: varchar('effect', { length: 128 }).notNull(),
66
+ /** Higher runs first among due jobs. From the effect's schedule. */
67
+ priority: integer('priority').notNull().default(0),
68
+ attempts: integer('attempts').notNull().default(0),
69
+ availableAt: timestamp('available_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
70
+ leaseUntil: timestamp('lease_until', { withTimezone: true, mode: 'date' }),
71
+ leaseToken: varchar('lease_token'),
72
+ lastError: text('last_error'),
73
+ /** Set when attempts run out. A human clears it or deletes the row. */
74
+ deadAt: timestamp('dead_at', { withTimezone: true, mode: 'date' }),
75
+ },
76
+ t => [
77
+ unique('journal_jobs_event_seq_effect_unique').on(t.eventSeq, t.effect),
78
+ index('journal_jobs_due_idx')
79
+ .on(t.priority.desc(), t.availableAt)
80
+ .where(sql`${t.deadAt} is null`),
81
+ ],
82
+ );
83
+
84
+ const receipts = table('journal_receipts', {
85
+ /** The app's idempotency key. Scope it to the caller when it comes from outside (tenant, actor, source). */
86
+ key: varchar('key', { length: 256 }).primaryKey(),
87
+ command: varchar('command', { length: 128 }).notNull(),
88
+ /** The parsed input, after a JSON round trip. A repeat with another input is a conflict. */
89
+ input: jsonb('input').notNull(),
90
+ /** `{ value }` once the run finished; `{}` for a command that returned nothing; null while it runs. */
91
+ result: jsonb('result').$type<{ readonly value?: unknown }>(),
92
+ commandId: varchar('command_id').notNull(),
93
+ recordedAt: timestamp('recorded_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
94
+ });
95
+
96
+ const cursors = table('journal_cursors', {
97
+ /** The async projection's name. */
98
+ name: varchar('name', { length: 128 }).primaryKey(),
99
+ /** Global scan progress: every event with a seq at or below this was looked at, matching or not. */
100
+ seq: bigint('seq', { mode: 'number' }).notNull().default(0),
101
+ updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
102
+ });
103
+
104
+ return { events, jobs, receipts, cursors };
105
+ }
106
+
107
+ export type JournalTables = ReturnType<typeof journalTables>;
package/src/storage.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The entity row and stored values: load and save the row, exact-id checks, value
3
+ * comparison, the JSON round-trip check, freezing, and the root-database guard.
4
+ * Imports only leaf files (`types`, `errors`). Must not import `journal`, `api`,
5
+ * `write`, `command`, `history` or `worker`.
6
+ */
7
+ import type { z } from 'zod';
8
+ import { eq, getTableColumns, is } from 'drizzle-orm';
9
+ import { PgAsyncTransaction } from 'drizzle-orm/pg-core';
10
+
11
+ import type {
12
+ AnyEntity,
13
+ AnyEventDef,
14
+ DbHandle,
15
+ EntityDef,
16
+ EntityId,
17
+ EntityTable,
18
+ ReducerRow,
19
+ RootDb,
20
+ Row,
21
+ } from './types.js';
22
+ import { JournalError } from './errors.js';
23
+
24
+ /** A select row has every column of its table. The check is real; the guard narrows. */
25
+ export function isRowOf<Tbl extends EntityTable>(table: Tbl, value: object): value is Row<Tbl> {
26
+ return Object.keys(getTableColumns(table)).every(column => column in value);
27
+ }
28
+
29
+ type EntityRowSource = Pick<AnyEntity, 'table' | 'keyColumn'>;
30
+
31
+ export async function loadRow(
32
+ db: DbHandle,
33
+ entity: EntityRowSource,
34
+ id: string,
35
+ lock: boolean,
36
+ ): Promise<object | null> {
37
+ const query = db.select().from(entity.table).where(eq(entity.keyColumn, id)).limit(1);
38
+ const rows = lock ? await query.for('update') : await query;
39
+ return rows[0] ?? null;
40
+ }
41
+
42
+ /**
43
+ * The id the caller passes must be exactly the key the database stores. A database can treat
44
+ * two spellings as one key (a `uuid` ignores case), but the log is keyed by the raw string,
45
+ * so a second spelling would split one entity's history in two.
46
+ */
47
+ const CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
48
+
49
+ /**
50
+ * With no row to compare against (a create and delete in one write, a replay of a missing row),
51
+ * `assertStoredId` sees nothing, so the id is checked up front, before any database access: not
52
+ * empty, within the key's length limit in code points (the column or the log would cut it), and,
53
+ * for a `uuid` key, lower-case and hyphenated.
54
+ */
55
+ export function assertExactId(entity: Pick<AnyEntity, 'kind' | 'uuidKey' | 'keyLimit'>, id: string): void {
56
+ const fail = (reason: string): never => {
57
+ throw new JournalError('ID_MISMATCH', entity.kind, id, reason);
58
+ };
59
+ if (id === '') fail('the id is empty');
60
+ // (Surrogates are U+D800 to U+DFFF.) A lone surrogate is encoded by the driver as U+FFFD, so it would reach the history of another id.
61
+ // Iterating by code point yields a paired surrogate as one astral character, never as a surrogate.
62
+ for (const ch of id) {
63
+ const point = ch.codePointAt(0) ?? 0;
64
+ if (point >= 55_296 && point <= 57_343) {
65
+ fail(
66
+ 'the id contains an unpaired surrogate, so it is not well-formed text and the database would store a different key',
67
+ );
68
+ }
69
+ }
70
+ const length = [...id].length;
71
+ if (length > entity.keyLimit.max) {
72
+ const owner = entity.keyLimit.source === 'column' ? "the key column's" : "the journal's";
73
+ fail(`the id is ${length} characters but the limit is ${entity.keyLimit.max}, ${owner} limit`);
74
+ }
75
+ if (entity.uuidKey && !CANONICAL_UUID.test(id)) {
76
+ fail(`${JSON.stringify(id)} is not a canonical uuid; uuid keys must be lower-case and hyphenated`);
77
+ }
78
+ }
79
+
80
+ export function assertStoredId(entity: Pick<AnyEntity, 'kind' | 'key'>, row: object | null, id: string): void {
81
+ if (row === null) return;
82
+ const stored: unknown = Reflect.get(row, entity.key);
83
+ if (stored !== id) {
84
+ throw new JournalError(
85
+ 'ID_MISMATCH',
86
+ entity.kind,
87
+ id,
88
+ `the stored key is ${JSON.stringify(stored)} but the id given is ${JSON.stringify(id)}; use the stored form`,
89
+ );
90
+ }
91
+ }
92
+
93
+ /** Insert, update or delete the row so it matches `next`. Returns the stored row. */
94
+ export async function saveReplayed<Tbl extends EntityTable, Key extends string>(
95
+ db: DbHandle,
96
+ entity: EntityDef<string, Tbl, AnyEventDef, unknown, Key>,
97
+ id: EntityId<Tbl, Key>,
98
+ current: object | null,
99
+ next: ReducerRow<Tbl, Key> | null,
100
+ ): Promise<Row<Tbl> | null> {
101
+ const erased: EntityTable = entity.table;
102
+ if (next === null) {
103
+ if (current !== null) await db.delete(erased).where(eq(entity.keyColumn, id));
104
+ return null;
105
+ }
106
+ const saved =
107
+ current === null
108
+ ? await db.insert(erased).values(next).returning()
109
+ : await db.update(erased).set(next).where(eq(entity.keyColumn, id)).returning();
110
+ const row = saved[0];
111
+ if (row === undefined || !isRowOf(entity.table, row)) {
112
+ throw new Error(`journal: saving ${id} returned no complete row`);
113
+ }
114
+ return row;
115
+ }
116
+
117
+ export function toRecord(value: object): Record<string, unknown> {
118
+ return { ...value };
119
+ }
120
+
121
+ export function showValue(value: unknown): string {
122
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString();
123
+ if (typeof value === 'bigint') return `${value}n`;
124
+ if (typeof value === 'object' && value !== null) {
125
+ try {
126
+ return JSON.stringify(value);
127
+ } catch {
128
+ return Object.prototype.toString.call(value);
129
+ }
130
+ }
131
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
132
+ }
133
+
134
+ /**
135
+ * The row the database returned must hold, for each of `columns`, the value the reducers produced.
136
+ * Replay recomputes from reducer output, so a value the database rounds, trims or alters would make
137
+ * replay and the table drift apart. Uses the same comparison as `verify`.
138
+ */
139
+ export function assertStoredValues(
140
+ entity: Pick<AnyEntity, 'kind'>,
141
+ id: string,
142
+ produced: object,
143
+ saved: object,
144
+ columns: Iterable<string>,
145
+ ): void {
146
+ const want = toRecord(produced);
147
+ const have = toRecord(saved);
148
+ for (const column of columns) {
149
+ if (!sameValue(want[column], have[column])) {
150
+ throw new JournalError(
151
+ 'VALUE_MISMATCH',
152
+ entity.kind,
153
+ id,
154
+ `column "${column}": the reducer produced ${showValue(want[column])} but the database stored ${showValue(have[column])}`,
155
+ );
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Same own enumerable keys and same values. A key holding `undefined` is the same as a missing key, as in JSON. */
161
+ function sameKeys(
162
+ left: Record<string, unknown>,
163
+ right: Record<string, unknown>,
164
+ same: (a: unknown, b: unknown) => boolean,
165
+ ): boolean {
166
+ const keys = Object.keys(left).filter(key => left[key] !== undefined);
167
+ const rightKeys = Object.keys(right).filter(key => right[key] !== undefined);
168
+ return keys.length === rightKeys.length && keys.every(key => same(left[key], right[key]));
169
+ }
170
+
171
+ /** An object literal shape: prototype `Object.prototype` or `null`. A `Set`, `Map`, `Date` or class instance is not one. */
172
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
173
+ if (typeof value !== 'object' || value === null) return false;
174
+ const proto: unknown = Object.getPrototypeOf(value);
175
+ return proto === Object.prototype || proto === null;
176
+ }
177
+
178
+ /**
179
+ * Freeze a plain object or array and everything inside it. A `Date` is frozen
180
+ * itself; its internals are not touched. Anything else is left alone.
181
+ */
182
+ export function deepFreeze<T>(value: T): T {
183
+ if (Array.isArray(value)) {
184
+ for (const item of value) deepFreeze(item);
185
+ Object.freeze(value);
186
+ } else if (isPlainObject(value)) {
187
+ for (const item of Object.values(value)) deepFreeze(item);
188
+ Object.freeze(value);
189
+ } else if (value instanceof Date) {
190
+ Object.freeze(value);
191
+ }
192
+ return value;
193
+ }
194
+
195
+ /** Same length, and every index from 0 up compared. A hole is not equal to a value, `null` and `undefined` included. */
196
+ function sameArray(a: readonly unknown[], b: readonly unknown[], same: (x: unknown, y: unknown) => boolean): boolean {
197
+ if (a.length !== b.length) return false;
198
+ for (let index = 0; index < a.length; index++) {
199
+ if (index in a !== index in b) return false;
200
+ if (!same(a[index], b[index])) return false;
201
+ }
202
+ return true;
203
+ }
204
+
205
+ /**
206
+ * Deep equality for stored rows: `verify` compares what replay produced with what the table holds.
207
+ * Strict: primitives, `Date`, value objects, arrays and plain objects only. A value object is a class
208
+ * instance with an `equals` method, such as a Temporal value from a custom column: it equals another
209
+ * instance of the same class when its `equals` says so. A `Map`, `Set`, other class instance or typed
210
+ * array is never equal to anything, because JSON storage would lose its contents.
211
+ */
212
+ export function sameValue(a: unknown, b: unknown): boolean {
213
+ if (typeof a === 'function' || typeof a === 'symbol') return false;
214
+ if (typeof a !== 'object' || a === null) return Object.is(a, b);
215
+ if (a instanceof Date) return b instanceof Date && a.getTime() === b.getTime();
216
+ if (Array.isArray(a)) return Array.isArray(b) && sameArray(a, b, sameValue);
217
+ if (isValueObject(a)) return Object.getPrototypeOf(a) === Object.getPrototypeOf(b) && a.equals(b) === true;
218
+ if (isPlainObject(a) && isPlainObject(b)) return sameKeys(a, b, sameValue);
219
+ return false;
220
+ }
221
+
222
+ /**
223
+ * A class instance that defines its own equality, like Temporal's `Instant` or `PlainDate`. A column
224
+ * whose driver rebuilds the same class on read round-trips it; a lossy column hands back another type
225
+ * or another value, and `equals` then reports the difference.
226
+ */
227
+ function isValueObject(value: object): value is { equals(other: unknown): unknown } {
228
+ return !isPlainObject(value) && 'equals' in value && typeof value.equals === 'function';
229
+ }
230
+
231
+ /**
232
+ * Deep equality for a value that must survive JSON storage: primitives, arrays and
233
+ * plain objects only. A `Set` has no enumerable keys and would otherwise equal `{}`.
234
+ */
235
+ function sameJson(a: unknown, b: unknown): boolean {
236
+ if (Object.is(a, b)) return true;
237
+ if (Array.isArray(a) && Array.isArray(b)) {
238
+ return sameArray(a, b, sameJson);
239
+ }
240
+ if (isPlainObject(a) && isPlainObject(b)) return sameKeys(a, b, sameJson);
241
+ return false;
242
+ }
243
+
244
+ /**
245
+ * Throws `UNSTABLE_SCHEMA` unless the value survives JSON storage and a second parse
246
+ * unchanged. Replay depends on this. A value JSON cannot encode (a `bigint`, a cycle)
247
+ * is the same failure, with the serializer's error as `cause`.
248
+ */
249
+ export function assertRoundTrips(schema: z.ZodType, value: unknown, kind: string, id: string, what: string): void {
250
+ let stable: boolean;
251
+ try {
252
+ // Not structuredClone: the point is what JSON storage does to the value (Dates become strings).
253
+ const text = JSON.stringify(value);
254
+ const stored: unknown = JSON.parse(text);
255
+ const again = schema.safeParse(stored);
256
+ stable = again.success && sameJson(again.data, value);
257
+ } catch (error) {
258
+ throw new JournalError('UNSTABLE_SCHEMA', kind, id, `${what}: the value cannot be stored as JSON`, {
259
+ cause: error,
260
+ });
261
+ }
262
+ if (!stable)
263
+ throw new JournalError(
264
+ 'UNSTABLE_SCHEMA',
265
+ kind,
266
+ id,
267
+ `${what}: the schema must accept its own stored output unchanged`,
268
+ );
269
+ }
270
+
271
+ export function assertRootDb(db: RootDb, verb: string): void {
272
+ if (is(db, PgAsyncTransaction)) {
273
+ throw new Error(
274
+ `journal: ${verb} needs the root database, not a transaction: effects would run before the outer transaction commits`,
275
+ );
276
+ }
277
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared fixture for the package tests: an in-memory PGlite, the journal
3
+ * tables, and a small `orders` domain. Excluded from the build.
4
+ */
5
+ import { integer, pgTable, varchar } from 'drizzle-orm/pg-core';
6
+ import { z } from 'zod';
7
+
8
+ import { initJournal } from './journal.js';
9
+ import { journalTables } from './pg.js';
10
+ import { openJournalDb } from './testing.js';
11
+
12
+ export type OrderId = `ord_${string}`;
13
+ export const orderId = (n: number): OrderId => `ord_${n}`;
14
+
15
+ export const orders = pgTable('orders', {
16
+ id: varchar('id', { length: 256 }).primaryKey().$type<OrderId>(),
17
+ customerId: varchar('customer_id', { length: 256 }).notNull(),
18
+ total: integer('total').notNull(),
19
+ status: varchar('status', { enum: ['placed', 'paid', 'cancelled'] }).notNull(),
20
+ note: varchar('note', { length: 256 }).notNull().default('none'),
21
+ version: integer('version').notNull().default(0),
22
+ });
23
+
24
+ export const customerOrderCounts = pgTable('customer_order_counts', {
25
+ customerId: varchar('customer_id', { length: 256 }).primaryKey(),
26
+ placed: integer('placed').notNull().default(0),
27
+ });
28
+
29
+ /** A table whose key is not called `id`. */
30
+ export type AccountId = `acc_${string}`;
31
+ export const accountId = (n: number): AccountId => `acc_${n}`;
32
+
33
+ export const profiles = pgTable('profiles', {
34
+ accountId: varchar('account_id', { length: 256 }).primaryKey().$type<AccountId>(),
35
+ nick: varchar('nick', { length: 64 }).notNull(),
36
+ moveCount: integer('move_count').notNull().default(0),
37
+ version: integer('version').notNull().default(0),
38
+ });
39
+
40
+ export const tables = journalTables();
41
+
42
+ export const j = initJournal({ meta: z.object({ actorId: z.string().min(1) }) });
43
+
44
+ export const OrderPlaced = j.event(
45
+ 'OrderPlaced',
46
+ z.object({ customerId: z.string().min(1), total: z.int().positive() }),
47
+ );
48
+ export const OrderPaid = j.event('OrderPaid', z.object({ amount: z.int().positive() }));
49
+ /** A note is free text a person wrote, so it has an erased form. */
50
+ export const OrderNoted = j.event('OrderNoted', z.object({ note: z.string().min(1) }), {
51
+ erased: () => ({ note: '[erased]' }),
52
+ });
53
+ export const OrderCancelled = j.event('OrderCancelled', z.object({ reason: z.string().min(1) }));
54
+ export const Unrelated = j.event('Unrelated', z.object({}));
55
+
56
+ export const order = j
57
+ .entity('order', orders)
58
+ .create(OrderPlaced, e => ({ customerId: e.data.customerId, total: e.data.total, status: 'placed' }))
59
+ // Spread style: still allowed, the patch is merged onto the state.
60
+ .on(OrderPaid, row => ({ ...row, status: 'paid' }))
61
+ // Patch style: only the column that changed.
62
+ .on(OrderNoted, (_row, e) => ({ note: e.data.note }))
63
+ .on(OrderCancelled, () => null);
64
+
65
+ export const ProfileOpened = j.event('ProfileOpened', z.object({ nick: z.string().min(1) }));
66
+ export const ProfileMoved = j.event('ProfileMoved', z.object({}));
67
+ export const ProfileClosed = j.event('ProfileClosed', z.object({}));
68
+ /** May be the first event of a profile or a later one. */
69
+ export const ProfileFlagged = j.event('ProfileFlagged', z.object({ nick: z.string().min(1) }));
70
+
71
+ export const profile = j
72
+ .entity('profile', profiles, { key: 'accountId' })
73
+ .create(ProfileOpened, e => ({ nick: e.data.nick, moveCount: 0 }))
74
+ .create(ProfileFlagged, e => ({ nick: e.data.nick, moveCount: 0 }))
75
+ // Every create sets `moveCount`, so it is a number here: no `?? 0`.
76
+ .on(ProfileMoved, row => ({ moveCount: row.moveCount + 1 }))
77
+ .on(ProfileFlagged, (_row, e) => ({ nick: e.data.nick }))
78
+ .on(ProfileClosed, () => null);
79
+
80
+ export const openDb = () => openJournalDb({ ...tables, orders, customerOrderCounts, profiles });
81
+
82
+ export const meta = { actorId: 'usr_test' };
package/src/testing.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Test helper: an in-memory PGlite with a schema pushed, the fixture every
3
+ * journal test rebuilds by hand. `@electric-sql/pglite` and `drizzle-kit` are
4
+ * optional peers. Import this module only from tests. This is the one file
5
+ * that may import them.
6
+ */
7
+ import { PGlite } from '@electric-sql/pglite';
8
+ import { pushSchema } from 'drizzle-kit/api-postgres';
9
+ import { drizzle } from 'drizzle-orm/pglite';
10
+
11
+ /**
12
+ * Open a fresh in-memory database with `schema` (the journal tables and the
13
+ * app's tables) pushed. Returns the Drizzle database and the PGlite client;
14
+ * call `client.close()` when the test file ends.
15
+ */
16
+ export async function openJournalDb(schema: Record<string, unknown>) {
17
+ const client = new PGlite('memory://');
18
+ await client.waitReady;
19
+ const db = drizzle({ client });
20
+ const { apply } = await pushSchema(schema, db);
21
+ await apply();
22
+ return { db, client };
23
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The guard that allows one journal operation per transaction at a time.
3
+ * Its `WeakMap` is the only module-level state in the library, and it must stay
4
+ * module-scoped: see the comment on it.
5
+ * Imports nothing from this package. `write` and `command` import it.
6
+ */
7
+ import { is } from 'drizzle-orm';
8
+ import { PgAsyncTransaction } from 'drizzle-orm/pg-core';
9
+
10
+ /** Passed to `then` twice: waits for a promise to settle, whether it resolves or rejects. */
11
+ const ignoreSettlement = (): undefined => undefined;
12
+
13
+ /**
14
+ * Savepoints are a stack. Two journal operations open on one transaction at the
15
+ * same time release each other's savepoint, so a caught failure of one could
16
+ * erase what the other wrote. A transaction therefore holds one operation at a
17
+ * time. A second one is refused: it waits for the first to settle (so the
18
+ * caller's rollback undoes it whole), then rejects. A child runs on its own
19
+ * savepoint handle, so nesting is fine. The root database opens a separate
20
+ * transaction per operation and is never busy.
21
+ *
22
+ * The map is module-scoped on purpose. Two journals can run on one transaction,
23
+ * and then they share its savepoint stack, so they must share this guard too.
24
+ * Do not move it into the journal factory.
25
+ */
26
+ const inFlight = new WeakMap<object, Promise<unknown>>();
27
+ export function oneAtATime<T>(handle: object, what: string, work: () => Promise<T>): Promise<T> {
28
+ if (!is(handle, PgAsyncTransaction)) return work();
29
+ const busy = inFlight.get(handle);
30
+ if (busy !== undefined) {
31
+ return busy.then(ignoreSettlement, ignoreSettlement).then(() => {
32
+ throw new Error(
33
+ `journal: ${what} started on a transaction that already has a journal operation in flight; await operations on one transaction one at a time`,
34
+ );
35
+ });
36
+ }
37
+ // eslint-disable-next-line drizzle/enforce-delete-with-where -- WeakMap#delete, not a Drizzle query
38
+ const running = work().finally(() => inFlight.delete(handle));
39
+ inFlight.set(handle, running);
40
+ return running;
41
+ }