@arki/journal 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -2
  3. package/dist/api.d.ts +336 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +2 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/command.d.ts +27 -0
  8. package/dist/command.d.ts.map +1 -0
  9. package/dist/command.js +157 -0
  10. package/dist/command.js.map +1 -0
  11. package/dist/debug.d.ts +7 -0
  12. package/dist/debug.d.ts.map +1 -0
  13. package/dist/debug.js +8 -0
  14. package/dist/debug.js.map +1 -0
  15. package/dist/dot.d.ts +102 -0
  16. package/dist/dot.d.ts.map +1 -0
  17. package/dist/dot.js +159 -0
  18. package/dist/dot.js.map +1 -0
  19. package/dist/entity.d.ts +56 -0
  20. package/dist/entity.d.ts.map +1 -0
  21. package/dist/entity.js +174 -0
  22. package/dist/entity.js.map +1 -0
  23. package/dist/errors.d.ts +47 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +112 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/event.d.ts +9 -0
  28. package/dist/event.d.ts.map +1 -0
  29. package/dist/event.js +19 -0
  30. package/dist/event.js.map +1 -0
  31. package/dist/fold.d.ts +29 -0
  32. package/dist/fold.d.ts.map +1 -0
  33. package/dist/fold.js +49 -0
  34. package/dist/fold.js.map +1 -0
  35. package/dist/handlers.d.ts +26 -0
  36. package/dist/handlers.d.ts.map +1 -0
  37. package/dist/handlers.js +130 -0
  38. package/dist/handlers.js.map +1 -0
  39. package/dist/history.d.ts +49 -0
  40. package/dist/history.d.ts.map +1 -0
  41. package/dist/history.js +259 -0
  42. package/dist/history.js.map +1 -0
  43. package/dist/hooks.d.ts +14 -0
  44. package/dist/hooks.d.ts.map +1 -0
  45. package/dist/hooks.js +67 -0
  46. package/dist/hooks.js.map +1 -0
  47. package/dist/id.d.ts +18 -0
  48. package/dist/id.d.ts.map +1 -0
  49. package/dist/id.js +41 -0
  50. package/dist/id.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +5 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/journal.d.ts +51 -0
  56. package/dist/journal.d.ts.map +1 -0
  57. package/dist/journal.js +181 -0
  58. package/dist/journal.js.map +1 -0
  59. package/dist/listen.d.ts +37 -0
  60. package/dist/listen.d.ts.map +1 -0
  61. package/dist/listen.js +90 -0
  62. package/dist/listen.js.map +1 -0
  63. package/dist/orpc.d.ts +21 -0
  64. package/dist/orpc.d.ts.map +1 -0
  65. package/dist/orpc.js +57 -0
  66. package/dist/orpc.js.map +1 -0
  67. package/dist/pg.d.ts +537 -0
  68. package/dist/pg.d.ts.map +1 -0
  69. package/dist/pg.js +80 -0
  70. package/dist/pg.js.map +1 -0
  71. package/dist/storage.d.ts +52 -0
  72. package/dist/storage.d.ts.map +1 -0
  73. package/dist/storage.js +221 -0
  74. package/dist/storage.js.map +1 -0
  75. package/dist/testing.d.ts +19 -0
  76. package/dist/testing.d.ts.map +1 -0
  77. package/dist/testing.js +23 -0
  78. package/dist/testing.js.map +1 -0
  79. package/dist/transaction-guard.d.ts +2 -0
  80. package/dist/transaction-guard.d.ts.map +1 -0
  81. package/dist/transaction-guard.js +38 -0
  82. package/dist/transaction-guard.js.map +1 -0
  83. package/dist/types.d.ts +414 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +2 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/worker.d.ts +109 -0
  88. package/dist/worker.d.ts.map +1 -0
  89. package/dist/worker.js +458 -0
  90. package/dist/worker.js.map +1 -0
  91. package/dist/write.d.ts +39 -0
  92. package/dist/write.d.ts.map +1 -0
  93. package/dist/write.js +287 -0
  94. package/dist/write.js.map +1 -0
  95. package/docs/agent-guide.md +324 -0
  96. package/llms.txt +40 -0
  97. package/package.json +99 -4
  98. package/src/api.ts +465 -0
  99. package/src/command.ts +242 -0
  100. package/src/debug.ts +8 -0
  101. package/src/dot.ts +296 -0
  102. package/src/entity.ts +377 -0
  103. package/src/errors.ts +134 -0
  104. package/src/event.ts +26 -0
  105. package/src/fold.ts +97 -0
  106. package/src/handlers.ts +262 -0
  107. package/src/history.ts +390 -0
  108. package/src/hooks.ts +85 -0
  109. package/src/id.ts +54 -0
  110. package/src/index.ts +76 -0
  111. package/src/journal.ts +256 -0
  112. package/src/listen.ts +109 -0
  113. package/src/orpc.ts +59 -0
  114. package/src/pg.ts +107 -0
  115. package/src/storage.ts +277 -0
  116. package/src/test-support.ts +82 -0
  117. package/src/testing.ts +23 -0
  118. package/src/transaction-guard.ts +40 -0
  119. package/src/types.ts +439 -0
  120. package/src/worker.ts +609 -0
  121. package/src/write.ts +434 -0
package/src/command.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Command execution: parse the input, open the transaction, give the handler a
3
+ * context bound to it, and refuse to close while an operation is still running.
4
+ * May import `api`, `write` (types), `storage`, `transaction-guard` and leaf files.
5
+ * Must not import `journal`, `history` or `worker`.
6
+ */
7
+ import type { z } from 'zod';
8
+ import { eq, is } from 'drizzle-orm';
9
+ import { PgAsyncTransaction } from 'drizzle-orm/pg-core';
10
+
11
+ import type { Command, CommandContext, RunOptions } from './api.js';
12
+ import type { HookPointInternal } from './hooks.js';
13
+ import type { JournalTables } from './pg.js';
14
+ import type { CommandHookContext, DbHandle, Trace, TransactionSource } from './types.js';
15
+ import type { Writer } from './write.js';
16
+ import { debugCommand } from './debug.js';
17
+ import { JournalError } from './errors.js';
18
+ import { commandId, correlationId } from './id.js';
19
+ import { assertRoundTrips, sameValue } from './storage.js';
20
+ import { oneAtATime } from './transaction-guard.js';
21
+
22
+ /** What JSON storage keeps of `value`. `undefined` has no JSON form, so it comes back as `undefined` here too. */
23
+ const throughJson = (value: unknown): unknown =>
24
+ // eslint-disable-next-line unicorn/prefer-structured-clone -- JSON semantics on purpose: this is what the column stores
25
+ value === undefined ? undefined : JSON.parse(JSON.stringify(value));
26
+
27
+ /** What command execution needs from the journal. */
28
+ export type CommandDeps<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>> = {
29
+ readonly metaSchema: Meta;
30
+ readonly writeWithValidatedMeta: Writer<Meta, Tx>['writeWithValidatedMeta'];
31
+ /** Wakes this journal's workers. Called after a root-database run that queued a job. */
32
+ readonly wake: () => void;
33
+ /** The command hooks. Frozen by the first run. */
34
+ readonly hooks: HookPointInternal<CommandHookContext<z.output<Meta>>>;
35
+ readonly tables: Pick<JournalTables, 'receipts'>;
36
+ };
37
+
38
+ export function createCommandRunner<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>>(
39
+ deps: CommandDeps<Meta, Tx>,
40
+ ) {
41
+ type MetaOut = z.output<Meta>;
42
+ const { metaSchema, writeWithValidatedMeta, hooks } = deps;
43
+ const receipts = deps.tables.receipts;
44
+
45
+ /**
46
+ * Take the key for this run, or find the run that holds it. Returns `{ replay }` with the
47
+ * stored result when a finished run holds the key with the same command and input. The
48
+ * insert waits on an uncommitted holder, so a concurrent repeat sees its outcome.
49
+ */
50
+ const claimReceipt = async (
51
+ tx: DbHandle,
52
+ key: string,
53
+ name: string,
54
+ input: unknown,
55
+ own: string,
56
+ ): Promise<{ readonly replay: unknown } | undefined> => {
57
+ const inserted = await tx
58
+ .insert(receipts)
59
+ .values({ key, command: name, input: throughJson(input) ?? null, commandId: own })
60
+ .onConflictDoNothing()
61
+ .returning({ key: receipts.key });
62
+ if (inserted.length > 0) return undefined;
63
+ const [held] = await tx.select().from(receipts).where(eq(receipts.key, key));
64
+ if (held === undefined)
65
+ throw new Error(`journal: idempotency key ${JSON.stringify(key)} vanished while it was being claimed`);
66
+ // Only a run inside this transaction can see an unfinished receipt: a concurrent one waited on the insert.
67
+ if (held.result === null)
68
+ throw new Error(
69
+ `journal: idempotency key ${JSON.stringify(key)} is held by a run of "${held.command}" that has not finished; a key cannot be reused inside its own transaction`,
70
+ );
71
+ if (held.command !== name)
72
+ throw new JournalError(
73
+ 'CONFLICT',
74
+ 'receipt',
75
+ key,
76
+ `the idempotency key is held by command "${held.command}", not "${name}"`,
77
+ );
78
+ if (!sameValue(held.input, throughJson(input) ?? null))
79
+ throw new JournalError(
80
+ 'CONFLICT',
81
+ 'receipt',
82
+ key,
83
+ `the idempotency key was used by command "${name}" with a different input`,
84
+ );
85
+ return { replay: 'value' in held.result ? held.result.value : undefined };
86
+ };
87
+
88
+ /** Store the result under the key. It must survive JSON, because the replay returns the stored form. */
89
+ const storeReceipt = async (tx: DbHandle, key: string, name: string, value: unknown): Promise<void> => {
90
+ const stored = throughJson(value);
91
+ if (!sameValue(stored, value))
92
+ throw new JournalError(
93
+ 'UNSTABLE_SCHEMA',
94
+ 'command',
95
+ name,
96
+ `the result of command "${name}" does not survive JSON storage; a command run with an idempotency key must return plain data, because a repeat returns the stored result`,
97
+ );
98
+ await tx
99
+ .update(receipts)
100
+ .set({ result: stored === undefined ? {} : { value: stored } })
101
+ .where(eq(receipts.key, key));
102
+ };
103
+
104
+ /**
105
+ * `journal.run` and `context.run` share this. `getValidatedMeta` gives the parsed meta:
106
+ * the root call parses it, a child inherits it. It runs inside the one-at-a-time
107
+ * guard, so a bad meta on a busy transaction waits like any other rejection.
108
+ * `onJobs` hears how many jobs each write queued, in this command and its children.
109
+ * `parent` is the trace this run continues: the chain's correlation id and cause are
110
+ * inherited, the command id is this run's own.
111
+ */
112
+ const runCommand = <S extends z.ZodType, R>(
113
+ source: TransactionSource<Tx>,
114
+ command: Command<S, R, MetaOut, Tx>,
115
+ input: z.input<S>,
116
+ getValidatedMeta: () => Promise<MetaOut>,
117
+ onJobs: (count: number) => void,
118
+ parent: Omit<Trace, 'commandId'>,
119
+ key: string | undefined,
120
+ ): Promise<Awaited<R>> =>
121
+ oneAtATime(source, `command "${command.name}"`, async (): Promise<Awaited<R>> => {
122
+ hooks.freeze();
123
+ const meta = await getValidatedMeta();
124
+ const parsed = await command.input.parseAsync(input);
125
+ const own = commandId();
126
+ const trace: Trace = { ...parent, commandId: own };
127
+ debugCommand.debug('{commandName}: input ok ({own}, {correlationId})', {
128
+ commandName: command.name,
129
+ own,
130
+ correlationId: trace.correlationId,
131
+ });
132
+ return source.transaction(async (tx): Promise<Awaited<R>> => {
133
+ // Every operation the handler starts through this context. The transaction
134
+ // must not close while one is still running: its work would land outside.
135
+ const started: Promise<unknown>[] = [];
136
+ let unfinished = 0;
137
+ const track = <T>(operation: Promise<T>): Promise<T> => {
138
+ started.push(operation);
139
+ unfinished += 1;
140
+ const done = () => {
141
+ unfinished -= 1;
142
+ };
143
+ void operation.then(done, done);
144
+ return operation;
145
+ };
146
+ const context: CommandContext<MetaOut, Tx> = {
147
+ tx,
148
+ meta,
149
+ commandId: own,
150
+ correlationId: trace.correlationId,
151
+ write: (entity, id, events, opts) =>
152
+ track(writeWithValidatedMeta(tx, entity, id, () => meta, events, trace, onJobs, opts)),
153
+ run: (child, childInput, childOpts) =>
154
+ track(
155
+ runCommand(tx, child, childInput, () => Promise.resolve(meta), onJobs, trace, childOpts?.idempotencyKey),
156
+ ),
157
+ };
158
+ let result: Awaited<R>;
159
+ try {
160
+ // Inside the transaction: a hook that throws rolls the command back cleanly.
161
+ result = await hooks.run(
162
+ {
163
+ name: command.name,
164
+ input: parsed,
165
+ meta,
166
+ commandId: own,
167
+ correlationId: trace.correlationId,
168
+ idempotencyKey: key ?? null,
169
+ },
170
+ async (): Promise<Awaited<R>> => {
171
+ if (key === undefined) return await command.handler(parsed, context);
172
+ const held = await claimReceipt(tx, key, command.name, parsed, own);
173
+ if (held !== undefined) {
174
+ debugCommand.debug('{commandName}: replayed from receipt {key}', { commandName: command.name, key });
175
+ // The receipt holds what this same command returned earlier, in the JSON form
176
+ // `storeReceipt` checked against the live value. This is the one cast in the library.
177
+ return held.replay as Awaited<R>;
178
+ }
179
+ const value = await command.handler(parsed, context);
180
+ await storeReceipt(tx, key, command.name, value);
181
+ return value;
182
+ },
183
+ );
184
+ } catch (error) {
185
+ // The handler's own error is the one to report; the rollback undoes the rest.
186
+ await Promise.allSettled(started);
187
+ throw error;
188
+ }
189
+ if (unfinished > 0) {
190
+ const count = unfinished;
191
+ await Promise.allSettled(started);
192
+ throw new Error(
193
+ `journal: command "${command.name}" returned with ${count} journal operation${count === 1 ? '' : 's'} still running; await every write and run before the handler returns`,
194
+ );
195
+ }
196
+ debugCommand.debug('{commandName}: done', { commandName: command.name });
197
+ return result;
198
+ });
199
+ });
200
+
201
+ /**
202
+ * `journal.run`: parses `meta` once for the whole command. `cause` is set only by an
203
+ * effect's `ctx.run`: the event the job was for. Its correlation id carries over unless
204
+ * `opts.correlationId` says otherwise; with neither, the run starts a new chain.
205
+ */
206
+ const run = <S extends z.ZodType, R>(
207
+ source: TransactionSource<Tx>,
208
+ command: Command<S, R, MetaOut, Tx>,
209
+ input: NoInfer<z.input<S>>,
210
+ meta: z.input<Meta>,
211
+ opts?: RunOptions,
212
+ cause?: { readonly seq: number; readonly correlationId: string | null },
213
+ ): Promise<Awaited<R>> => {
214
+ let queued = 0;
215
+ return runCommand(
216
+ source,
217
+ command,
218
+ input,
219
+ async () => {
220
+ const parsed = await metaSchema.parseAsync(meta);
221
+ // No entity yet: the error names the command instead.
222
+ assertRoundTrips(metaSchema, parsed, 'command', command.name, 'meta');
223
+ return parsed;
224
+ },
225
+ count => {
226
+ queued += count;
227
+ },
228
+ {
229
+ causeSeq: cause?.seq ?? null,
230
+ correlationId: opts?.correlationId ?? cause?.correlationId ?? correlationId(),
231
+ },
232
+ opts?.idempotencyKey,
233
+ ).then(result => {
234
+ // The root database has committed by now. Inside a caller's transaction
235
+ // only the caller knows when the commit happens, so the caller wakes.
236
+ if (queued > 0 && !is(source, PgAsyncTransaction)) deps.wake();
237
+ return result;
238
+ });
239
+ };
240
+
241
+ return run;
242
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { getLogger } from '@arki/log';
2
+
3
+ /** Enable with `DEBUG=arki:journal:*`. */
4
+ export const debugWrite = getLogger('arki:journal:write');
5
+ export const debugCommand = getLogger('arki:journal:command');
6
+ export const debugReplay = getLogger('arki:journal:replay');
7
+ export const debugDrain = getLogger('arki:journal:drain');
8
+ export const debugWorker = getLogger('arki:journal:worker');
package/src/dot.ts ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `@arki/dot` adapter.
3
+ *
4
+ * - `journal(...)` is the plugin. It publishes the journal as `services.journal`
5
+ * and runs one worker per plugin instance between `start` and `stop`.
6
+ * - `journalHandlers(...)` is the feature slice. A feature lists its projections
7
+ * and effects with it, and `journal({ j, tables, features })` collects them.
8
+ *
9
+ * The app's `db` plugin must publish the root Drizzle database under the `db` key.
10
+ *
11
+ * `@arki/dot` is an optional peer. Import this module only inside a Dot app.
12
+ * Feature slices are read by shape: this file never imports `@arki/feature`.
13
+ */
14
+ import type { z } from 'zod';
15
+
16
+ import type { Plugin, Service, Token } from '@arki/dot/plugin';
17
+ import { DotPluginError, plugin, service } from '@arki/dot/plugin';
18
+
19
+ import type { Journal, JournalDefinitions } from './api.js';
20
+ import type { JournalTables } from './pg.js';
21
+ import type {
22
+ DbHandle,
23
+ Effect,
24
+ JournalWorker,
25
+ Needs,
26
+ Projection,
27
+ RootDb,
28
+ Services,
29
+ ServicesFor,
30
+ Tap,
31
+ TransactionSource,
32
+ WakeListener,
33
+ } from './types.js';
34
+ import { debugWorker } from './debug.js';
35
+ import { JournalError } from './errors.js';
36
+ import { bindEffect, bindTap } from './handlers.js';
37
+
38
+ export const JOURNAL_ACTION_META_SCHEMA = '@arki/journal/action-meta@1';
39
+
40
+ export const JOURNAL_PLUGIN_ERROR_CODES = { badSlice: 'JOURNAL_PLUGIN_E001' } as const;
41
+
42
+ /** One effect in the app manifest: async work that the journal's job table feeds in. */
43
+ export type JournalDotAction = {
44
+ readonly id: string;
45
+ readonly binding: 'journal';
46
+ readonly direction: 'in';
47
+ readonly address: string;
48
+ readonly metaSchema: typeof JOURNAL_ACTION_META_SCHEMA;
49
+ readonly meta: { readonly entities: string[]; readonly events: string[] };
50
+ };
51
+
52
+ /** What one feature hands to the journal. Its effects already hold the feature's services. */
53
+ export type JournalHandlers<Meta = unknown, Tx = DbHandle, AppDb = RootDb> = {
54
+ readonly projections: readonly Projection<Meta, Tx>[];
55
+ readonly effects: readonly Effect<Meta, AppDb>[];
56
+ readonly taps: readonly Tap<Meta, AppDb>[];
57
+ };
58
+
59
+ /**
60
+ * The `@arki/feature` slice contract a `journalHandlers(...)` declaration produces.
61
+ * `TServices` is what the feature's effects need: the feature must declare it in `needs`.
62
+ */
63
+ export type JournalFeatureSlice<Meta = unknown, Tx = DbHandle, AppDb = RootDb, TServices = unknown> = {
64
+ readonly key: 'journal';
65
+ readonly actions: readonly JournalDotAction[];
66
+ readonly resolve: (services: TServices) => JournalHandlers<Meta, Tx, AppDb>;
67
+ };
68
+
69
+ function effectAction(effect: {
70
+ readonly name: string;
71
+ readonly entities: readonly { readonly kind: string }[];
72
+ readonly types: ReadonlySet<string>;
73
+ }): JournalDotAction {
74
+ return {
75
+ id: `${effect.name}.run`,
76
+ binding: 'journal',
77
+ direction: 'in',
78
+ address: effect.name,
79
+ metaSchema: JOURNAL_ACTION_META_SCHEMA,
80
+ meta: { entities: effect.entities.map(entity => entity.kind), events: [...effect.types] },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Declare a feature's projections and effects as a boot-time slice
86
+ * (`defineFeature(..., { use: [journalHandlers({ projections, effects })] })`).
87
+ * The payload lands under `journal` in the feature's slice token, where
88
+ * `journal({ j, tables, features })` collects it.
89
+ *
90
+ * An effect with needs (`j.effect(name, entity, { mailer: need<Mailer>() }, on)`) gets
91
+ * them from the booted services of this feature, by name. The feature must declare
92
+ * each one in its own `needs`: the slice's `resolve` does not type-check otherwise.
93
+ * Two features may use the same need name for two different services.
94
+ *
95
+ * Actions: one per effect. Projections add none: they run inside the write, not
96
+ * at an app boundary.
97
+ */
98
+ export function journalHandlers<
99
+ Meta = unknown,
100
+ Tx = DbHandle,
101
+ AppDb = RootDb,
102
+ const E extends readonly Effect<Meta, AppDb, unknown, Needs>[] = readonly [],
103
+ const T extends readonly Tap<Meta, AppDb, Needs>[] = readonly [],
104
+ >(handlers: {
105
+ readonly projections?: readonly Projection<Meta, Tx>[];
106
+ readonly effects?: E;
107
+ readonly taps?: T;
108
+ }): JournalFeatureSlice<Meta, Tx, AppDb, ServicesFor<E> & ServicesFor<T>> {
109
+ const { projections = [], effects = [], taps = [] } = handlers;
110
+ return {
111
+ key: 'journal',
112
+ actions: effects.map(effect => effectAction(effect)),
113
+ // The kernel hands the feature's services over untyped, so they are read by name here.
114
+ resolve: (services: unknown) => ({
115
+ projections,
116
+ effects: effects.map(effect => bindEffect(effect, isServices(services) ? services : {})),
117
+ taps: taps.map(tap => bindTap(tap, isServices(services) ? services : {})),
118
+ }),
119
+ };
120
+ }
121
+
122
+ const isServices = (value: unknown): value is Services => typeof value === 'object' && value !== null;
123
+
124
+ type WorkerOptions = {
125
+ /** Run the async lane in this process. Default `true`. */
126
+ readonly worker?: boolean;
127
+ readonly pollMs?: number;
128
+ readonly batchSize?: number;
129
+ readonly leaseSeconds?: number;
130
+ /** See `journal.worker`: required when the journal has taps, unless `taps: false`. */
131
+ readonly listen?: WakeListener;
132
+ readonly taps?: boolean;
133
+ };
134
+
135
+ /** Direct mode: the app composed the journal itself. */
136
+ export type JournalDirectOptions<
137
+ Meta extends z.ZodObject,
138
+ Tx extends TransactionSource<Tx>,
139
+ AppDb extends RootDb,
140
+ > = WorkerOptions & { readonly journal: Journal<Meta, Tx, AppDb> };
141
+
142
+ /** Feature mode: the plugin composes the journal from the features' `journalHandlers` slices. */
143
+ export type JournalFeatureOptions<
144
+ Meta extends z.ZodObject,
145
+ Tx extends TransactionSource<Tx>,
146
+ AppDb extends RootDb,
147
+ > = WorkerOptions & {
148
+ readonly j: Pick<JournalDefinitions<Meta, Tx, AppDb>, 'compose'>;
149
+ readonly tables: JournalTables;
150
+ /** `tokens(features)` from `@arki/feature/dot`. A feature without a journal slice adds nothing. */
151
+ readonly features: readonly Token<object, string>[];
152
+ };
153
+
154
+ /**
155
+ * `RootDb` again on purpose: a Drizzle transaction fits `AppDb` by shape, and must not fit here.
156
+ * `TransactionSource<Tx>`: the worker's database runs the commands of effects, so it opens `Tx`.
157
+ */
158
+ export type JournalPlugin<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb> = Plugin<
159
+ { db: AppDb & RootDb & TransactionSource<Tx> },
160
+ { journal: Journal<Meta, Tx, AppDb> }
161
+ >;
162
+
163
+ const isHandler = (value: unknown): boolean =>
164
+ typeof value === 'object' &&
165
+ value !== null &&
166
+ 'name' in value &&
167
+ typeof value.name === 'string' &&
168
+ 'run' in value &&
169
+ typeof value.run === 'function';
170
+
171
+ /** The kernel hands services over untyped, so the slice shape is checked here, once, at boot. */
172
+ function isJournalHandlers(value: unknown): value is JournalHandlers {
173
+ return (
174
+ typeof value === 'object' &&
175
+ value !== null &&
176
+ 'projections' in value &&
177
+ Array.isArray(value.projections) &&
178
+ value.projections.every(item => isHandler(item)) &&
179
+ 'effects' in value &&
180
+ Array.isArray(value.effects) &&
181
+ value.effects.every(item => isHandler(item)) &&
182
+ 'taps' in value &&
183
+ Array.isArray(value.taps) &&
184
+ value.taps.every(item => isHandler(item))
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Throws when another handler of this kind already has the name, and names the
190
+ * feature tokens that hold the two. `j.compose` makes the same check, but it gets
191
+ * one flat list and cannot say which feature a handler came from.
192
+ */
193
+ function claimName(
194
+ kind: 'effect' | 'projection' | 'tap',
195
+ owners: Map<string, string>,
196
+ name: string,
197
+ feature: string,
198
+ ): void {
199
+ const owner = owners.get(name);
200
+ if (owner !== undefined) {
201
+ const where = owner === feature ? `feature "${feature}", twice` : `features "${owner}" and "${feature}"`;
202
+ throw new JournalError('BAD_DEFINITION', kind, name, `journal: duplicate ${kind} name "${name}" (${where})`);
203
+ }
204
+ owners.set(name, feature);
205
+ }
206
+
207
+ /**
208
+ * Read each feature's `journal` slice from the booted services, in feature order.
209
+ * Refuses a projection name or an effect name that is listed twice.
210
+ */
211
+ function collectHandlers(
212
+ features: readonly Token<object, string>[],
213
+ services: Readonly<Record<string, unknown>>,
214
+ ): JournalHandlers {
215
+ const projections: Projection[] = [];
216
+ const effects: Effect[] = [];
217
+ const taps: Tap[] = [];
218
+ const projectionOwners = new Map<string, string>();
219
+ const effectOwners = new Map<string, string>();
220
+ const tapOwners = new Map<string, string>();
221
+ for (const feature of features) {
222
+ const slices = services[feature.key];
223
+ const slice = typeof slices === 'object' && slices !== null && 'journal' in slices ? slices.journal : undefined;
224
+ if (slice === undefined) {
225
+ debugWorker.debug('[journal] feature "{featureKey}" did not contribute a journal slice', {
226
+ featureKey: feature.key,
227
+ });
228
+ continue;
229
+ }
230
+ if (!isJournalHandlers(slice)) {
231
+ throw new DotPluginError({
232
+ code: JOURNAL_PLUGIN_ERROR_CODES.badSlice,
233
+ message: `[journal] feature token "${feature.key}" holds a \`journal\` slice that is not lists of projections, effects and taps.`,
234
+ remediation: 'Declare the slice with journalHandlers({ projections, effects, taps }) from @arki/journal/dot.',
235
+ docsUrl: 'https://github.com/arki-labs/arki/blob/HEAD/packages/journal/docs/agent-guide.md#dot-boot-errors',
236
+ });
237
+ }
238
+ for (const { name } of slice.projections) claimName('projection', projectionOwners, name, feature.key);
239
+ for (const { name } of slice.effects) claimName('effect', effectOwners, name, feature.key);
240
+ for (const { name } of slice.taps) claimName('tap', tapOwners, name, feature.key);
241
+ projections.push(...slice.projections);
242
+ effects.push(...slice.effects);
243
+ taps.push(...slice.taps);
244
+ }
245
+ return { projections, effects, taps };
246
+ }
247
+
248
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
249
+ options: JournalDirectOptions<Meta, Tx, AppDb>,
250
+ ): JournalPlugin<Meta, Tx, AppDb>;
251
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
252
+ options: JournalFeatureOptions<Meta, Tx, AppDb>,
253
+ ): JournalPlugin<Meta, Tx, AppDb>;
254
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
255
+ options: JournalDirectOptions<Meta, Tx, AppDb> | JournalFeatureOptions<Meta, Tx, AppDb>,
256
+ ) {
257
+ let composed: Journal<Meta, Tx, AppDb> | undefined;
258
+ let worker: JournalWorker | undefined;
259
+ // Each feature token is a need, so the kernel boots the features first and refuses a missing one.
260
+ const featureNeeds = 'features' in options ? options.features.map(feature => [feature.key, feature]) : [];
261
+ const needs: { readonly db: Service<AppDb & RootDb & TransactionSource<Tx>> } = {
262
+ ...Object.fromEntries(featureNeeds),
263
+ db: service<AppDb & RootDb & TransactionSource<Tx>>(),
264
+ };
265
+
266
+ return plugin({
267
+ name: 'journal',
268
+ needs,
269
+ configure(ctx) {
270
+ ctx.registerService('journal', 'custom');
271
+ ctx.declareProvides('journal');
272
+ },
273
+ boot(services) {
274
+ composed =
275
+ 'journal' in options
276
+ ? options.journal
277
+ : options.j.compose({ tables: options.tables, ...collectHandlers(options.features, services) });
278
+ return { journal: composed };
279
+ },
280
+ async start({ db }) {
281
+ if (options.worker === false) return;
282
+ worker = composed?.worker(db, {
283
+ pollMs: options.pollMs,
284
+ batchSize: options.batchSize,
285
+ leaseSeconds: options.leaseSeconds,
286
+ listen: options.listen,
287
+ taps: options.taps,
288
+ });
289
+ await worker?.start();
290
+ },
291
+ async stop() {
292
+ await worker?.stop();
293
+ worker = undefined;
294
+ },
295
+ });
296
+ }