@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/hooks.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Around-hooks: the one way to extend a command run or an effect run from the
3
+ * outside. A hook receives the context and `next`, and runs inside the same
4
+ * transaction (commands) or lease (effects). The rules, all enforced here:
5
+ * `next` exactly once, or throw before it to stop; a rejection of `next` is the
6
+ * hook's rejection too; the context is frozen; hooks run in registration order,
7
+ * the first one outermost; registration is frozen once the journal has done work.
8
+ * Leaf file: may import `errors` only.
9
+ */
10
+ import { JournalError } from './errors.js';
11
+
12
+ /** A hook. `next` runs the rest of the chain and the work itself. It gives no result and takes no input. */
13
+ export type Around<Ctx> = (ctx: Ctx, next: () => Promise<void>) => Promise<void>;
14
+
15
+ /** What the app sees: register only. */
16
+ export type HookPoint<Ctx> = {
17
+ /** Wrap every run. Throws `BAD_DEFINITION` once the journal has run a command, a drain or a worker. */
18
+ around(hook: Around<Ctx>): void;
19
+ };
20
+
21
+ export function createHookPoint<Ctx extends object>(what: 'command' | 'effect') {
22
+ const hooks: Around<Ctx>[] = [];
23
+ let frozen = false;
24
+
25
+ const point: HookPoint<Ctx> = {
26
+ around(hook) {
27
+ if (frozen) {
28
+ throw new JournalError(
29
+ 'BAD_DEFINITION',
30
+ 'hook',
31
+ what,
32
+ `journal: ${what} hooks are frozen once the journal has run a command, a drain or a worker; register hooks at boot, before any work`,
33
+ );
34
+ }
35
+ hooks.push(hook);
36
+ },
37
+ };
38
+
39
+ /** From now on, `around` throws. Called by the first `run` or `drain`. */
40
+ const freeze = (): void => {
41
+ frozen = true;
42
+ };
43
+
44
+ /** Run `work` inside every registered hook. The result is the work's own; no hook can change it. */
45
+ const run = async <T>(ctx: Ctx, work: () => Promise<T>): Promise<T> => {
46
+ if (hooks.length === 0) return work();
47
+ const frozenCtx = Object.freeze(ctx);
48
+ let result: { readonly value: T } | undefined;
49
+ const layer = async (index: number): Promise<void> => {
50
+ const hook = hooks[index];
51
+ if (hook === undefined) {
52
+ result = { value: await work() };
53
+ return;
54
+ }
55
+ let calls = 0;
56
+ let failure: { readonly error: unknown } | undefined;
57
+ const next = async (): Promise<void> => {
58
+ calls += 1;
59
+ if (calls > 1)
60
+ throw new Error(`journal: a ${what} hook called next twice; next runs the ${what} and may run once`);
61
+ try {
62
+ await layer(index + 1);
63
+ } catch (error) {
64
+ failure = { error };
65
+ throw error;
66
+ }
67
+ };
68
+ await hook(frozenCtx, next);
69
+ // A hook that swallowed the failure would turn a rolled-back command into a success for the caller.
70
+ if (failure !== undefined) throw failure.error;
71
+ if (calls === 0) {
72
+ throw new Error(
73
+ `journal: a ${what} hook returned without calling next; call next to run the ${what}, or throw to stop it`,
74
+ );
75
+ }
76
+ };
77
+ await layer(0);
78
+ if (result === undefined) throw new Error(`journal: the ${what} did not run`);
79
+ return result.value;
80
+ };
81
+
82
+ return { point, freeze, run };
83
+ }
84
+
85
+ export type HookPointInternal<Ctx extends object> = ReturnType<typeof createHookPoint<Ctx>>;
package/src/id.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { createId } from '@paralleldrive/cuid2';
2
+
3
+ import { JournalError } from './errors.js';
4
+
5
+ /**
6
+ * Same shape as `createPrefixedId` in `@arki/db/id`. That module is not
7
+ * imported because its value export pulls `drizzle-orm/pg-core` and
8
+ * `@arki/contracts` into browser bundles.
9
+ */
10
+ const prefixed = <P extends string>(prefix: P): `${P}${string}` => `${prefix}${createId()}`;
11
+
12
+ export const eventId = () => prefixed('evt_');
13
+ export const jobId = () => prefixed('job_');
14
+ export const commandId = () => prefixed('cmd_');
15
+ export const correlationId = () => prefixed('cor_');
16
+ export const leaseToken = () => createId();
17
+
18
+ const NAME_KINDS = {
19
+ 'entity kind': 64,
20
+ 'event type': 128,
21
+ 'effect name': 128,
22
+ } as const;
23
+
24
+ /**
25
+ * Throws unless `name` fits the `varchar` column the journal stores it in: non-empty,
26
+ * well-formed UTF-16 (a lone surrogate would be stored as U+FFFD and alias another name),
27
+ * and at most the column length in code points. Hermes-safe: no `isWellFormed`, no lookbehind.
28
+ */
29
+ export function assertStoredName(what: keyof typeof NAME_KINDS, name: string): void {
30
+ const limit = NAME_KINDS[what];
31
+ const shown = JSON.stringify(name);
32
+ if (name === '') throw new JournalError('BAD_DEFINITION', what, shown, `journal: ${what} must not be empty`);
33
+ let length = 0;
34
+ for (const ch of name) {
35
+ const point = ch.codePointAt(0);
36
+ if (point !== undefined && point >= 55_296 && point <= 57_343) {
37
+ throw new JournalError(
38
+ 'BAD_DEFINITION',
39
+ what,
40
+ shown,
41
+ `journal: ${what} ${shown} has an unpaired surrogate; the database would store it as U+FFFD`,
42
+ );
43
+ }
44
+ length++;
45
+ }
46
+ if (length > limit) {
47
+ throw new JournalError(
48
+ 'BAD_DEFINITION',
49
+ what,
50
+ shown,
51
+ `journal: ${what} ${shown} is ${length} characters; the limit is ${limit}`,
52
+ );
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ export { initJournal } from './journal.js';
2
+ export { need } from './handlers.js';
3
+ export type {
4
+ Command,
5
+ CommandContext,
6
+ CommandRunner,
7
+ Journal,
8
+ JournalDefinitions,
9
+ ReplayResult,
10
+ RootCommandRunner,
11
+ RunOptions,
12
+ CallOptions,
13
+ VerifyResult,
14
+ ForgetResult,
15
+ NextEvents,
16
+ WriteInput,
17
+ WriteOptions,
18
+ WriteResult,
19
+ } from './api.js';
20
+ export { CommandError, JournalError, isCommandError, isJournalError } from './errors.js';
21
+ export type { CommandErrorCode, JournalErrorCode } from './errors.js';
22
+ export type { GuaranteedCreateKeys, EntityBuilder, OnReducerRow, OnOnlyEntityBuilder } from './entity.js';
23
+ export type {
24
+ AnyEntity,
25
+ AnyEventDef,
26
+ CreateReducer,
27
+ DbHandle,
28
+ Effect,
29
+ EffectContext,
30
+ EffectHandlers,
31
+ EntityDef,
32
+ EntityId,
33
+ EntityIdOf,
34
+ EntityInfo,
35
+ EntityTable,
36
+ EventDef,
37
+ EventOptions,
38
+ Upcaster,
39
+ UpcasterSteps,
40
+ ProjectionOptions,
41
+ Tap,
42
+ TapContext,
43
+ TapHandlers,
44
+ Unlisten,
45
+ WakeListener,
46
+ CommandHookContext,
47
+ EffectHookContext,
48
+ EventsOf,
49
+ IdTable,
50
+ KeyColumn,
51
+ Need,
52
+ Needed,
53
+ Needs,
54
+ ReducerValues,
55
+ OnReducer,
56
+ Patch,
57
+ Pending,
58
+ PendingOf,
59
+ Projection,
60
+ ProjectionHandlers,
61
+ Recorded,
62
+ RecordedOf,
63
+ Reducers,
64
+ ReducerRow,
65
+ RootDb,
66
+ Row,
67
+ Services,
68
+ ServicesFor,
69
+ TransactionSource,
70
+ JournalWorker,
71
+ StrandedJobs,
72
+ } from './types.js';
73
+ export type { JournalTables } from './pg.js';
74
+ export type { Around, HookPoint } from './hooks.js';
75
+ export { listenOnPool } from './listen.js';
76
+ export type { ListenClient, ListenPool } from './listen.js';
package/src/journal.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The entry point: `initJournal`, the definition helpers it returns, and
3
+ * `createJournal`, which checks the handlers, builds each part with its
4
+ * dependencies and wires the public methods.
5
+ * May import every other file. No implementation file may import this one.
6
+ */
7
+ import { getTableColumns, getTableName, is, lt } from 'drizzle-orm';
8
+ import { getTableConfig, PgColumn } from 'drizzle-orm/pg-core';
9
+ import { z } from 'zod';
10
+
11
+ import type { Journal, JournalDefinitions, RootCommandRunner } from './api.js';
12
+ import type { EntityBuilder } from './entity.js';
13
+ import type { JournalTables } from './pg.js';
14
+ import type {
15
+ AnyEntity,
16
+ CommandHookContext,
17
+ DbHandle,
18
+ Effect,
19
+ EffectHookContext,
20
+ EntityTable,
21
+ IdTable,
22
+ KeyName,
23
+ Projection,
24
+ RootDb,
25
+ SingleKey,
26
+ Tap,
27
+ TransactionSource,
28
+ } from './types.js';
29
+ import { createCommandRunner } from './command.js';
30
+ import { defineEntity } from './entity.js';
31
+ import { JournalError } from './errors.js';
32
+ import { defineEvent } from './event.js';
33
+ import { bindEffect, bindTap, defineProjection, effectFor, tapFor } from './handlers.js';
34
+ import { createHistory } from './history.js';
35
+ import { createHookPoint } from './hooks.js';
36
+ import {
37
+ createAdmin,
38
+ createCatchUp,
39
+ createDrain,
40
+ createStrandedJobs,
41
+ createTapDispatch,
42
+ createWorkers,
43
+ } from './worker.js';
44
+ import { createWriter } from './write.js';
45
+
46
+ const emptyMeta = z.object({});
47
+ type EmptyMeta = typeof emptyMeta;
48
+
49
+ function initFor<Tx extends TransactionSource<Tx>, AppDb extends RootDb>() {
50
+ function init(): JournalDefinitions<EmptyMeta, Tx, AppDb>;
51
+ function init<Meta extends z.ZodObject>(options: { readonly meta: Meta }): JournalDefinitions<Meta, Tx, AppDb>;
52
+ function init<Meta extends z.ZodObject>(options?: {
53
+ readonly meta?: Meta;
54
+ }): JournalDefinitions<Meta, Tx, AppDb> | JournalDefinitions<EmptyMeta, Tx, AppDb> {
55
+ return options?.meta === undefined
56
+ ? createInit<EmptyMeta, Tx, AppDb>(emptyMeta)
57
+ : createInit<Meta, Tx, AppDb>(options.meta);
58
+ }
59
+ return init;
60
+ }
61
+
62
+ /**
63
+ * `initJournal({ meta })` types `write`'s `tx`, `events`' `{ tx }` and
64
+ * projection handlers with the structural `DbHandle`. `initJournal.tx<AppTx>()
65
+ * .create({ meta })` uses the app's own Drizzle transaction type instead, so
66
+ * repositories typed against it fit. `initJournal.db<AppDb>()` names the
67
+ * app's root database type: effects get it as `ctx.db`, and `drain` and
68
+ * `worker` then need it. Both are type-only steps and chain in either order.
69
+ */
70
+ export const initJournal = Object.assign(initFor<DbHandle, RootDb>(), {
71
+ tx: <Tx extends TransactionSource<Tx>>() => ({
72
+ create: initFor<Tx, RootDb>(),
73
+ db: <AppDb extends RootDb>() => ({ create: initFor<Tx, AppDb>() }),
74
+ }),
75
+ db: <AppDb extends RootDb>() => ({
76
+ create: initFor<DbHandle, AppDb>(),
77
+ tx: <Tx extends TransactionSource<Tx>>() => ({ create: initFor<Tx, AppDb>() }),
78
+ }),
79
+ });
80
+
81
+ /** A table with a column called `id`. The check is real; the guard narrows to `IdTable`. */
82
+ function hasIdColumn<Tbl extends EntityTable>(table: Tbl): table is Tbl & IdTable {
83
+ const columns: Record<string, unknown> = getTableColumns(table);
84
+ return is(columns.id, PgColumn);
85
+ }
86
+
87
+ function createInit<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
88
+ metaSchema: Meta,
89
+ ): JournalDefinitions<Meta, Tx, AppDb> {
90
+ type MetaOut = z.output<Meta>;
91
+ function entity<K extends string, Tbl extends IdTable>(
92
+ kind: K,
93
+ table: Tbl,
94
+ ): EntityBuilder<K, Tbl, never, MetaOut, 'id'>;
95
+ function entity<K extends string, Tbl extends EntityTable, Key extends KeyName<Tbl>>(
96
+ kind: K,
97
+ table: Tbl,
98
+ options: { readonly key: Key & SingleKey<Key> },
99
+ ): EntityBuilder<K, Tbl, never, MetaOut, Key>;
100
+ function entity<K extends string, Tbl extends EntityTable>(kind: K, table: Tbl, options?: { readonly key: string }) {
101
+ if (options !== undefined) {
102
+ return defineEntity<K, Tbl, MetaOut, string>(kind, table, options.key);
103
+ }
104
+ if (!hasIdColumn(table)) {
105
+ throw new JournalError(
106
+ 'BAD_DEFINITION',
107
+ 'entity',
108
+ kind,
109
+ `journal: entity "${kind}": table "${getTableName(table)}" has no "id" column; pass { key: 'columnName' }`,
110
+ );
111
+ }
112
+ return defineEntity<K, Tbl, MetaOut, 'id'>(kind, table, 'id');
113
+ }
114
+ return {
115
+ event: defineEvent,
116
+ entity,
117
+ command: (name, input, handler) => ({ name, input, handler }),
118
+ projection: (name, entity, on, opts) => defineProjection(name, entity, on, opts),
119
+ effect: effectFor<MetaOut, AppDb, RootCommandRunner<Meta, Tx>>(),
120
+ tap: tapFor<MetaOut, AppDb>(),
121
+ compose: config => {
122
+ const effects = (config.effects ?? []).map(effect => bindEffect(effect, config.services ?? {}));
123
+ const taps = (config.taps ?? []).map(tap => bindTap(tap, config.services ?? {}));
124
+ return createJournal<Meta, Tx, AppDb>(metaSchema, config.tables, config.projections ?? [], effects, taps);
125
+ },
126
+ };
127
+ }
128
+
129
+ /** Throws when two handlers of one kind share a name: the second would silently replace the first. */
130
+ function assertUniqueNames(
131
+ kind: 'effect' | 'projection' | 'tap',
132
+ handlers: readonly { readonly name: string }[],
133
+ ): void {
134
+ const seen = new Set<string>();
135
+ for (const { name } of handlers) {
136
+ if (seen.has(name))
137
+ throw new JournalError('BAD_DEFINITION', kind, name, `journal: duplicate ${kind} name "${name}"`);
138
+ seen.add(name);
139
+ }
140
+ }
141
+
142
+ function createJournal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
143
+ metaSchema: Meta,
144
+ tables: JournalTables,
145
+ projections: readonly Projection<z.output<Meta>, Tx>[],
146
+ effects: readonly Effect<z.output<Meta>, AppDb, RootCommandRunner<Meta, Tx>>[],
147
+ taps: readonly Tap<z.output<Meta>, AppDb>[] = [],
148
+ ): Journal<Meta, Tx, AppDb> {
149
+ assertUniqueNames('tap', taps);
150
+ const eventsSchema = getTableConfig(tables.events).schema;
151
+ const wakeChannel = eventsSchema === undefined ? 'journal_wake' : `${eventsSchema}_journal_wake`;
152
+ type MetaOut = z.output<Meta>;
153
+
154
+ assertUniqueNames('effect', effects);
155
+ assertUniqueNames('projection', projections);
156
+ const entitiesByKind = new Map<string, AnyEntity<MetaOut>>();
157
+ for (const handler of [...projections, ...effects]) {
158
+ for (const entity of handler.entities) entitiesByKind.set(entity.kind, entity);
159
+ }
160
+
161
+ // `journal.drain`, `journal.wake` and `journal.run` are read from the journal object on each
162
+ // call, so the worker loop, `run`, a bound runner and an effect's `ctx.run` always use the methods the object holds.
163
+ const history = createHistory<Meta>({ metaSchema, tables });
164
+ const inlineProjections = projections.filter(projection => projection.lane === 'inline');
165
+ const asyncProjections = projections.filter(projection => projection.lane === 'async');
166
+ const { write, adopt, writeWithValidatedMeta } = createWriter<Meta, Tx>({
167
+ metaSchema,
168
+ tables,
169
+ projections: inlineProjections,
170
+ asyncProjections,
171
+ effects,
172
+ history,
173
+ wakeChannel,
174
+ });
175
+ const commandHooks = createHookPoint<CommandHookContext<z.output<Meta>>>('command');
176
+ const effectHooks = createHookPoint<EffectHookContext<z.output<Meta>>>('effect');
177
+ const run = createCommandRunner<Meta, Tx>({
178
+ metaSchema,
179
+ writeWithValidatedMeta,
180
+ wake: () => journal.wake(),
181
+ hooks: commandHooks,
182
+ tables,
183
+ });
184
+ // The worker's database is also the source of every command an effect runs, so it opens `Tx`.
185
+ type WorkerDb = AppDb & TransactionSource<Tx>;
186
+ const drain = createDrain<Meta, WorkerDb, RootCommandRunner<Meta, Tx>>({
187
+ tables,
188
+ hooks: effectHooks,
189
+ effects,
190
+ history,
191
+ runOn: (db, cause) => (command, input, meta, call) => run(db, command, input, meta, call, cause),
192
+ });
193
+ const catching = createCatchUp<Meta, Tx>({ tables, projections: asyncProjections, history });
194
+ const dispatch = createTapDispatch<Meta, WorkerDb>({ tables, taps, history });
195
+ const workers = createWorkers<WorkerDb>({
196
+ drain: (db, opts) => journal.drain(db, opts),
197
+ catchUp: (db, opts) => journal.catchUp(db, opts),
198
+ dispatch,
199
+ tapNames: taps.map(tap => tap.name),
200
+ });
201
+ const strandedJobs = createStrandedJobs({ tables, effectNames: effects.map(effect => effect.name) });
202
+ const admin = createAdmin({ tables, wake: () => journal.wake() });
203
+
204
+ const journal: Journal<Meta, Tx, AppDb> = {
205
+ write: (tx, entity, id, input) => write(tx, entity, id, input),
206
+ adopt: (tx, entity, id, input) => adopt(tx, entity, id, input),
207
+ run: (source, command, input, meta, opts) => run(source, command, input, meta, opts),
208
+ bind: (source, meta, opts) => (command, input, call) =>
209
+ journal.run(source, command, input, meta, { ...opts, ...call }),
210
+ replay: (db, entity, id, opts) => history.replay(db, entity, id, opts),
211
+ verify: (db, entity, id) => history.verify(db, entity, id),
212
+ repair: (db, entity, id) => history.repair(db, entity, id),
213
+ forget: (db, entity, id) => history.forget(db, entity, id),
214
+ pruneReceipts: async (db, opts) => {
215
+ const gone = await db
216
+ .delete(tables.receipts)
217
+ .where(lt(tables.receipts.recordedAt, opts.before))
218
+ .returning({ key: tables.receipts.key });
219
+ return gone.length;
220
+ },
221
+ rerun: (db, projection, opts) => {
222
+ if (projection.lane === 'async')
223
+ throw new JournalError(
224
+ 'BAD_DEFINITION',
225
+ 'projection',
226
+ projection.name,
227
+ `journal: projection "${projection.name}" is async; use reset and catchUp instead of rerun`,
228
+ );
229
+ return history.rerun(db, projection, opts);
230
+ },
231
+ catchUp: (db, opts) => catching.catchUp(db, opts),
232
+ reset: (db, projection, clear) => catching.reset(db, projection, clear),
233
+ cursors: db => catching.cursors(db),
234
+ drain: (db, opts) => drain(db, opts),
235
+ worker: (db, opts) => workers.worker(db, opts),
236
+ wake: () => workers.wakeAll(),
237
+ strandedJobs: db => strandedJobs(db),
238
+ jobs: (db, opts) => admin.jobs(db, opts),
239
+ retry: (db, jobId) => admin.retry(db, jobId),
240
+ discard: (db, jobId) => admin.discard(db, jobId),
241
+ wakeChannel,
242
+ hooks: { command: commandHooks.point, effect: effectHooks.point },
243
+ catalog: {
244
+ entities: [...entitiesByKind.values()].map(entity => ({
245
+ kind: entity.kind,
246
+ table: getTableName(entity.table),
247
+ events: [...entity.events.keys()],
248
+ })),
249
+ projections: projections.map(projection => projection.name),
250
+ effects: effects.map(effect => effect.name),
251
+ taps: taps.map(tap => tap.name),
252
+ },
253
+ };
254
+
255
+ return journal;
256
+ }
package/src/listen.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `listenOnPool`: the `WakeListener` for a `pg` pool. It borrows one client from
3
+ * the pool for as long as the worker runs, `LISTEN`s on the channel, and forwards
4
+ * each payload. A dropped connection is released and replaced after `retryMs`;
5
+ * notifications sent while it was gone are lost, which taps accept and polling
6
+ * covers for jobs. Typed by shape, so this file imports no driver.
7
+ * Leaf file: may import `debug` and `types` only.
8
+ */
9
+ import type { WakeListener } from './types.js';
10
+ import { debugWorker } from './debug.js';
11
+
12
+ /**
13
+ * Let the process exit while this timer waits. Node and Bun return a timer object
14
+ * with `unref`; browsers, Deno's web API and Hermes return a number, which never
15
+ * holds a process open, so there is nothing to do.
16
+ */
17
+ export function unrefTimer(timer: unknown): void {
18
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function')
19
+ timer.unref();
20
+ }
21
+
22
+ /** The part of a `pg` pool client a listener uses. */
23
+ export type ListenClient = {
24
+ query(text: string): Promise<unknown>;
25
+ on(
26
+ event: 'notification',
27
+ listener: (message: { readonly channel: string; readonly payload?: string }) => void,
28
+ ): unknown;
29
+ on(event: 'error', listener: (error: Error) => void): unknown;
30
+ release(error?: Error | boolean): void;
31
+ };
32
+
33
+ /** The part of a `pg` pool a listener uses: `new pg.Pool(...)`, or a Drizzle node-postgres database's `$client`. */
34
+ export type ListenPool = { connect(): Promise<ListenClient> };
35
+
36
+ /**
37
+ * Hear `channel` on a dedicated client of `pool`: `journal.worker(db, { listen: listenOnPool(db.$client, journal.wakeChannel) })`.
38
+ * The first connection is made when the worker starts; if it fails, `start()` rejects.
39
+ */
40
+ export function listenOnPool(pool: ListenPool, channel: string, opts?: { readonly retryMs?: number }): WakeListener {
41
+ const retryMs = opts?.retryMs ?? 1000;
42
+ const quoted = `"${channel.replaceAll('"', '""')}"`;
43
+ return async onMessage => {
44
+ let stopped = false;
45
+ let current: ListenClient | undefined;
46
+ let timer: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ const connect = async (): Promise<void> => {
49
+ const client = await pool.connect();
50
+ let released = false;
51
+ const drop = (error?: Error) => {
52
+ if (released) return;
53
+ released = true;
54
+ if (current === client) current = undefined;
55
+ client.release(error ?? true);
56
+ };
57
+ client.on('notification', message => {
58
+ if (message.channel === channel && message.payload !== undefined) onMessage(message.payload);
59
+ });
60
+ client.on('error', error => {
61
+ debugWorker.debug('listen {channel}: connection lost: {lost}', { channel, lost: error.message });
62
+ drop(error);
63
+ schedule();
64
+ });
65
+ try {
66
+ await client.query(`LISTEN ${quoted}`);
67
+ } catch (error) {
68
+ drop(error instanceof Error ? error : new Error(String(error)));
69
+ throw error;
70
+ }
71
+ if (stopped) {
72
+ drop();
73
+ return;
74
+ }
75
+ current = client;
76
+ debugWorker.debug('listen {channel}: connected', { channel });
77
+ };
78
+
79
+ const schedule = (): void => {
80
+ if (stopped || timer !== undefined) return;
81
+ timer = setTimeout(() => {
82
+ timer = undefined;
83
+ connect().catch((error: unknown) => {
84
+ debugWorker.debug('listen {channel}: reconnect failed', {
85
+ channel,
86
+ error: error instanceof Error ? error.message : String(error),
87
+ });
88
+ schedule();
89
+ });
90
+ }, retryMs);
91
+ unrefTimer(timer);
92
+ };
93
+
94
+ await connect();
95
+ return async () => {
96
+ stopped = true;
97
+ if (timer !== undefined) clearTimeout(timer);
98
+ const client = current;
99
+ current = undefined;
100
+ if (client === undefined) return;
101
+ try {
102
+ await client.query(`UNLISTEN ${quoted}`);
103
+ client.release();
104
+ } catch (error) {
105
+ client.release(error instanceof Error ? error : true);
106
+ }
107
+ };
108
+ };
109
+ }
package/src/orpc.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `@orpc/server` adapter: what an oRPC client sees when a command fails.
3
+ *
4
+ * - `toORPCError(error)` maps one error.
5
+ * - `commandErrors()` is the middleware that maps whatever a procedure throws.
6
+ *
7
+ * `@orpc/server` is an optional peer. Import this module only in an app that
8
+ * serves oRPC. May import `errors` only; this is the one file that may import
9
+ * `@orpc/server`.
10
+ */
11
+ import { ORPCError, os } from '@orpc/server';
12
+
13
+ import { CommandError, isJournalError } from './errors.js';
14
+
15
+ /**
16
+ * Another request changed the same entity first, or the client's `expectedVersion`
17
+ * is behind. Nothing was written. Repeating the same request as it stands fails
18
+ * again when a version was given, so the client reloads first. The journal's own
19
+ * message names the entity kind and id; the client does not get those.
20
+ */
21
+ const CONFLICT_MESSAGE = 'This was changed by another request. Reload the current state and try again.';
22
+
23
+ /**
24
+ * - A `CommandError` becomes the `ORPCError` with the same code and message.
25
+ * - A `JournalError` with code `CONFLICT` becomes an `ORPCError('CONFLICT')`, a 409 the client can retry.
26
+ * - Anything else is returned unchanged: the app decides what it means.
27
+ *
28
+ * The original error is NOT attached as `cause`. The journal's message names the entity kind and
29
+ * id, and a host may log or serialize a cause. A host that wants the original logs it itself,
30
+ * in a middleware placed inside this one.
31
+ */
32
+ export function toORPCError(error: unknown): unknown {
33
+ if (error instanceof CommandError) {
34
+ return new ORPCError(error.code, { message: error.message });
35
+ }
36
+ if (isJournalError(error, 'CONFLICT')) {
37
+ return new ORPCError('CONFLICT', { message: CONFLICT_MESSAGE });
38
+ }
39
+ return error;
40
+ }
41
+
42
+ /**
43
+ * The middleware form of `toORPCError`. Add it once to the app's procedure
44
+ * builders, and no procedure needs a try/catch around a command.
45
+ *
46
+ * Order: put it INSIDE the app's own error middleware, so after it in the
47
+ * chain: `os.use(appErrors).use(commandErrors())`. A rejected rule is then
48
+ * already an `ORPCError` when the app's middleware sees it, and passes. Placed
49
+ * before it, the app's middleware sees a plain error first and answers 500.
50
+ */
51
+ export function commandErrors() {
52
+ return os.middleware(async ({ next }) => {
53
+ try {
54
+ return await next();
55
+ } catch (error) {
56
+ throw toORPCError(error);
57
+ }
58
+ });
59
+ }