@arki/journal 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts CHANGED
@@ -22,6 +22,7 @@ import type {
22
22
  EntityTable,
23
23
  EventDef,
24
24
  EventOptions,
25
+ HandlerServices,
25
26
  IdTable,
26
27
  JournalWorker,
27
28
  KeyName,
@@ -81,6 +82,27 @@ export type Command<S extends z.ZodType, R, in MetaOut, in Tx extends Transactio
81
82
  readonly name: string;
82
83
  readonly input: S;
83
84
  readonly handler: (input: z.output<S>, context: CommandContext<MetaOut, Tx>) => R;
85
+ /** The command's `authorize` option, when it has one. See {@link CommandOptions}. */
86
+ readonly authorize?: CommandAuthorize<z.output<S>, MetaOut, Tx>;
87
+ };
88
+ /**
89
+ * Decides whether the caller may run a command. It runs inside the command's transaction,
90
+ * after the input and `meta` are parsed, on every call: a call that replays an idempotency
91
+ * receipt runs it too, before the stored result is returned. Throw (a `CommandError`
92
+ * `FORBIDDEN`, usually) to refuse; the run rolls back and nothing is replayed.
93
+ */
94
+ export type CommandAuthorize<Input, in MetaOut, in Tx extends TransactionSource<Tx> = DbHandle> = (
95
+ input: Input,
96
+ context: { readonly tx: Tx; readonly meta: MetaOut },
97
+ ) => void | Promise<void>;
98
+ /** Options of `j.command`. */
99
+ export type CommandOptions<Input, MetaOut, Tx extends TransactionSource<Tx> = DbHandle> = {
100
+ /**
101
+ * Put the caller's permission check here, not in the handler. A repeat with an idempotency
102
+ * key does not run the handler, so a check inside it would not run either: the stored result
103
+ * would go to whoever replays the key. `authorize` runs on the first call and on every replay.
104
+ */
105
+ readonly authorize?: CommandAuthorize<Input, MetaOut, Tx>;
84
106
  };
85
107
  /**
86
108
  * What a command handler receives. `write` and `run` are bound to the command's
@@ -295,10 +317,16 @@ export type Journal<
295
317
  * covers only its own work, and the lease is renewed every half lease while the effect
296
318
  * runs. A lost lease aborts the effect's `ctx.signal`.
297
319
  * Once `signal` is aborted no further job is claimed; the running one sees it as `ctx.signal`.
320
+ * `services`: see `worker`.
298
321
  */
299
322
  drain(
300
323
  db: AppDb & RootDb & TransactionSource<Tx>,
301
- opts?: { readonly batchSize?: number; readonly signal?: AbortSignal; readonly leaseSeconds?: number },
324
+ opts?: {
325
+ readonly batchSize?: number;
326
+ readonly signal?: AbortSignal;
327
+ readonly leaseSeconds?: number;
328
+ readonly services?: HandlerServices;
329
+ },
302
330
  ): Promise<number>;
303
331
  /**
304
332
  * A loop around `drain`. The same database as `drain`. `stop()` aborts the running
@@ -318,6 +346,12 @@ export type Journal<
318
346
  readonly listen?: WakeListener;
319
347
  /** `false`: a jobs-only worker in a journal that has taps. Another process must listen. */
320
348
  readonly taps?: boolean;
349
+ /**
350
+ * The services of a journal composed with `services: 'worker'`, per effect and tap.
351
+ * A need without a value throws here, before the worker starts. A journal composed
352
+ * with its services ignores this.
353
+ */
354
+ readonly services?: HandlerServices;
321
355
  },
322
356
  ): JournalWorker;
323
357
  /**
@@ -409,6 +443,7 @@ export type JournalDefinitions<
409
443
  name: string,
410
444
  input: S,
411
445
  handler: (input: z.output<S>, context: CommandContext<z.output<Meta>, Tx>) => R,
446
+ options?: CommandOptions<z.output<S>, z.output<Meta>, Tx>,
412
447
  ): Command<S, R, z.output<Meta>, Tx>;
413
448
  /** `on` is `NoInfer` so `E` comes from `entity`; handlers are then contextually typed. */
414
449
  projection<E extends AnyEntity<z.output<Meta>>>(
@@ -434,7 +469,7 @@ export type JournalDefinitions<
434
469
  ): Effect<z.output<Meta>, AppDb, RootCommandRunner<Meta, Tx>, N>;
435
470
  /**
436
471
  * Build the runtime journal from the tables and the handlers. `services` holds one
437
- * value for every need of every listed effect, by name. A need without a value throws here.
472
+ * value for every need of every listed effect, by name. A need without a value throws here, unless `services` is `'worker'`.
438
473
  * `const`: the list is read as a tuple, so an effect with needs keeps them next to one without.
439
474
  */
440
475
  /** A tap: in-process, after commit, lossy. See `Tap`. Same shapes as `effect`. */
@@ -461,5 +496,12 @@ export type JournalDefinitions<
461
496
  } & ServicesOption<ServicesFor<E> & ServicesFor<T>>,
462
497
  ): Journal<Meta, Tx, AppDb>;
463
498
  };
464
- /** `services` is required as soon as one listed effect has a need. */
465
- type ServicesOption<S> = unknown extends S ? { readonly services?: Services } : { readonly services: S & Services };
499
+ /**
500
+ * `services` is required as soon as one listed effect has a need. `'worker'` leaves the needs
501
+ * open: writes never need them, and each `worker` or `drain` gives them (`HandlerServices`).
502
+ * That is for services that exist only after the journal: the Dot adapter's features boot
503
+ * after the journal they write through.
504
+ */
505
+ type ServicesOption<S> = unknown extends S
506
+ ? { readonly services?: Services | 'worker' }
507
+ : { readonly services: (S & Services) | 'worker' };
package/src/command.ts CHANGED
@@ -168,6 +168,9 @@ export function createCommandRunner<Meta extends z.ZodObject, Tx extends Transac
168
168
  idempotencyKey: key ?? null,
169
169
  },
170
170
  async (): Promise<Awaited<R>> => {
171
+ // Before the receipt: a receipt proves the command ran once, not that this caller
172
+ // may have its result. A replay is authorized like a first run.
173
+ if (command.authorize !== undefined) await command.authorize(parsed, { tx, meta });
171
174
  if (key === undefined) return await command.handler(parsed, context);
172
175
  const held = await claimReceipt(tx, key, command.name, parsed, own);
173
176
  if (held !== undefined) {
package/src/dot.ts CHANGED
@@ -1,19 +1,27 @@
1
1
  /**
2
- * `@arki/dot` adapter.
2
+ * `@arki/dot` adapter, in two parts:
3
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.
4
+ * - `journal(...)` is the driven part, for `.use()`. It publishes `services.journal`, so
5
+ * features can write through it. With `{ j, tables }` it composes the journal from the
6
+ * features' `journalHandlers` declarations. It reads them from the feature values, so it
7
+ * boots before the features.
8
+ * - `journalWorker(...)` is the driving part, for `.serve()`. It runs the effects, taps and
9
+ * async projections between `start` and `stop`. It gives each effect and tap the services
10
+ * of the feature that lists it.
8
11
  *
9
- * The app's `db` plugin must publish the root Drizzle database under the `db` key.
12
+ * The split works because writes never need an effect's services: a write only queues
13
+ * the effect's job. Inline projections run inside the write, and they get only the
14
+ * transaction. So the journal is composed with `services: 'worker'`, and the worker
15
+ * gives the services when it runs a handler.
10
16
  *
17
+ * The app's `db` plugin must publish the root Drizzle database under the `db` key.
11
18
  * `@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
19
  */
14
20
  import type { z } from 'zod';
15
21
 
16
- import type { Plugin, Service, Token } from '@arki/dot/plugin';
22
+ import type { AnyFeature, Driven, Driver } from '@arki/dot/feature';
23
+ import type { EmptyShape, Plugin, Service } from '@arki/dot/plugin';
24
+ import { driven, driver, sliceOf } from '@arki/dot/feature';
17
25
  import { DotPluginError, plugin, service } from '@arki/dot/plugin';
18
26
 
19
27
  import type { Journal, JournalDefinitions } from './api.js';
@@ -21,6 +29,7 @@ import type { JournalTables } from './pg.js';
21
29
  import type {
22
30
  DbHandle,
23
31
  Effect,
32
+ HandlerServices,
24
33
  JournalWorker,
25
34
  Needs,
26
35
  Projection,
@@ -33,11 +42,11 @@ import type {
33
42
  } from './types.js';
34
43
  import { debugWorker } from './debug.js';
35
44
  import { JournalError } from './errors.js';
36
- import { bindEffect, bindTap } from './handlers.js';
45
+ import { givenServices } from './handlers.js';
37
46
 
38
47
  export const JOURNAL_ACTION_META_SCHEMA = '@arki/journal/action-meta@1';
39
48
 
40
- export const JOURNAL_PLUGIN_ERROR_CODES = { badSlice: 'JOURNAL_PLUGIN_E001' } as const;
49
+ export const JOURNAL_PLUGIN_ERROR_CODES = { badSlice: 'ARKI_JOURNAL_PLUGIN_E001' } as const;
41
50
 
42
51
  /** One effect in the app manifest: async work that the journal's job table feeds in. */
43
52
  export type JournalDotAction = {
@@ -49,21 +58,27 @@ export type JournalDotAction = {
49
58
  readonly meta: { readonly entities: string[]; readonly events: string[] };
50
59
  };
51
60
 
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> = {
61
+ /** What one feature hands to the journal, as declared: an effect or a tap may still have needs. */
62
+ export type JournalDeclarations<Meta = unknown, Tx = DbHandle, AppDb = RootDb> = {
54
63
  readonly projections: readonly Projection<Meta, Tx>[];
55
- readonly effects: readonly Effect<Meta, AppDb>[];
56
- readonly taps: readonly Tap<Meta, AppDb>[];
64
+ readonly effects: readonly Effect<Meta, AppDb, unknown, Needs>[];
65
+ readonly taps: readonly Tap<Meta, AppDb, Needs>[];
57
66
  };
58
67
 
68
+ /** What a feature's `journal` slice publishes at boot: its services, for its effects and taps. */
69
+ export type JournalSlicePayload = { readonly services: Services };
70
+
59
71
  /**
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`.
72
+ * The feature slice a `journalHandlers(...)` declaration produces.
73
+ * `handlers` is read before the features boot, by `journal({ j, tables })`.
74
+ * `resolve` runs at the feature's boot. `TServices` is what the feature's effects and
75
+ * taps need: the feature must declare it in `needs`.
62
76
  */
63
77
  export type JournalFeatureSlice<Meta = unknown, Tx = DbHandle, AppDb = RootDb, TServices = unknown> = {
64
78
  readonly key: 'journal';
65
79
  readonly actions: readonly JournalDotAction[];
66
- readonly resolve: (services: TServices) => JournalHandlers<Meta, Tx, AppDb>;
80
+ readonly handlers: JournalDeclarations<Meta, Tx, AppDb>;
81
+ readonly resolve: (services: TServices) => JournalSlicePayload;
67
82
  };
68
83
 
69
84
  function effectAction(effect: {
@@ -82,14 +97,14 @@ function effectAction(effect: {
82
97
  }
83
98
 
84
99
  /**
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.
100
+ * Declare a feature's projections, effects and taps
101
+ * (`feature('orders', { use: [journalHandlers({ projections, effects })] })`).
102
+ * `journal({ j, tables })` reads them from the feature value and composes the journal.
89
103
  *
90
104
  * 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.
105
+ * them from the booted services of this feature, by name, when `journalWorker()` runs it.
106
+ * The feature must declare each one in its own `needs`: the slice's `resolve` does not
107
+ * type-check otherwise, and throws at the feature's boot when the types were bypassed.
93
108
  * Two features may use the same need name for two different services.
94
109
  *
95
110
  * Actions: one per effect. Projections add none: they run inside the write, not
@@ -110,53 +125,47 @@ export function journalHandlers<
110
125
  return {
111
126
  key: 'journal',
112
127
  actions: effects.map(effect => effectAction(effect)),
128
+ handlers: { projections, effects, taps },
113
129
  // 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
- }),
130
+ resolve: (services: unknown) => {
131
+ const given = isServices(services) ? services : {};
132
+ // Checked at the feature's boot, so a missing service never waits for the first job.
133
+ for (const effect of effects) givenServices('effect', effect, given);
134
+ for (const tap of taps) givenServices('tap', tap, given);
135
+ return { services: given };
136
+ },
119
137
  };
120
138
  }
121
139
 
122
140
  const isServices = (value: unknown): value is Services => typeof value === 'object' && value !== null;
123
141
 
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
142
  /** 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> };
143
+ export type JournalDirectOptions<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb> = {
144
+ readonly journal: Journal<Meta, Tx, AppDb>;
145
+ };
141
146
 
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 & {
147
+ /** Feature mode: the journal is composed from the features' `journalHandlers` declarations. */
148
+ export type JournalFeatureOptions<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb> = {
148
149
  readonly j: Pick<JournalDefinitions<Meta, Tx, AppDb>, 'compose'>;
149
150
  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
151
  };
153
152
 
154
153
  /**
155
154
  * `RootDb` again on purpose: a Drizzle transaction fits `AppDb` by shape, and must not fit here.
156
155
  * `TransactionSource<Tx>`: the worker's database runs the commands of effects, so it opens `Tx`.
157
156
  */
157
+ type JournalNeeds<Tx extends TransactionSource<Tx>, AppDb extends RootDb> = {
158
+ db: AppDb & RootDb & TransactionSource<Tx>;
159
+ };
160
+
158
161
  export type JournalPlugin<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb> = Plugin<
159
- { db: AppDb & RootDb & TransactionSource<Tx> },
162
+ JournalNeeds<Tx, AppDb>,
163
+ { journal: Journal<Meta, Tx, AppDb> }
164
+ >;
165
+
166
+ /** Feature mode: bound to the app's features when the app is built. */
167
+ export type JournalDriven<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb> = Driven<
168
+ JournalNeeds<Tx, AppDb>,
160
169
  { journal: Journal<Meta, Tx, AppDb> }
161
170
  >;
162
171
 
@@ -168,101 +177,91 @@ const isHandler = (value: unknown): boolean =>
168
177
  'run' in value &&
169
178
  typeof value.run === 'function';
170
179
 
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 {
180
+ const isHandlerList = (value: unknown): boolean => Array.isArray(value) && value.every(item => isHandler(item));
181
+
182
+ /** A slice's static `handlers` are read untyped from the feature, so their shape is checked here. */
183
+ function isJournalDeclarations(value: unknown): value is JournalDeclarations {
173
184
  return (
174
185
  typeof value === 'object' &&
175
186
  value !== null &&
176
187
  'projections' in value &&
177
- Array.isArray(value.projections) &&
178
- value.projections.every(item => isHandler(item)) &&
188
+ isHandlerList(value.projections) &&
179
189
  'effects' in value &&
180
- Array.isArray(value.effects) &&
181
- value.effects.every(item => isHandler(item)) &&
190
+ isHandlerList(value.effects) &&
182
191
  'taps' in value &&
183
- Array.isArray(value.taps) &&
184
- value.taps.every(item => isHandler(item))
192
+ isHandlerList(value.taps)
185
193
  );
186
194
  }
187
195
 
196
+ function badSlice(feature: AnyFeature, what: string): DotPluginError {
197
+ return new DotPluginError({
198
+ code: JOURNAL_PLUGIN_ERROR_CODES.badSlice,
199
+ message: `[journal] feature "${feature.name}" has a \`journal\` slice whose ${what}.`,
200
+ remediation: 'Declare the slice with journalHandlers({ projections, effects, taps }) from @arki/journal/dot.',
201
+ docsUrl: 'https://github.com/arki-labs/arki/blob/HEAD/packages/journal/docs/agent-guide.md#dot-boot-errors',
202
+ });
203
+ }
204
+
188
205
  /**
189
206
  * 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
207
+ * features that hold the two. `j.compose` makes the same check, but it gets
191
208
  * one flat list and cannot say which feature a handler came from.
192
209
  */
193
210
  function claimName(
194
211
  kind: 'effect' | 'projection' | 'tap',
195
- owners: Map<string, string>,
212
+ owners: Map<string, AnyFeature>,
196
213
  name: string,
197
- feature: string,
214
+ feature: AnyFeature,
198
215
  ): void {
199
- const owner = owners.get(name);
216
+ const owner = owners.get(`${kind}:${name}`);
200
217
  if (owner !== undefined) {
201
- const where = owner === feature ? `feature "${feature}", twice` : `features "${owner}" and "${feature}"`;
218
+ const where =
219
+ owner === feature ? `feature "${feature.name}", twice` : `features "${owner.name}" and "${feature.name}"`;
202
220
  throw new JournalError('BAD_DEFINITION', kind, name, `journal: duplicate ${kind} name "${name}" (${where})`);
203
221
  }
204
- owners.set(name, feature);
222
+ owners.set(`${kind}:${name}`, feature);
205
223
  }
206
224
 
207
225
  /**
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.
226
+ * Read each feature's `journal` slice declarations, in feature order. Refuses a name
227
+ * that is listed twice. `owners` maps `effect:<name>` and `tap:<name>` (and
228
+ * `projection:<name>`) to the feature that lists it.
210
229
  */
211
- function collectHandlers(
212
- features: readonly Token<object, string>[],
213
- services: Readonly<Record<string, unknown>>,
214
- ): JournalHandlers {
230
+ function readDeclarations(features: readonly AnyFeature[]): {
231
+ readonly declarations: JournalDeclarations;
232
+ readonly owners: ReadonlyMap<string, AnyFeature>;
233
+ } {
215
234
  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>();
235
+ const effects: Effect<unknown, RootDb, unknown, Needs>[] = [];
236
+ const taps: Tap<unknown, RootDb, Needs>[] = [];
237
+ const owners = new Map<string, AnyFeature>();
221
238
  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;
239
+ const slice = feature.use.find(item => item.key === 'journal');
224
240
  if (slice === undefined) {
225
- debugWorker.debug('[journal] feature "{featureKey}" did not contribute a journal slice', {
226
- featureKey: feature.key,
241
+ debugWorker.debug('[journal] feature "{featureName}" did not contribute a journal slice', {
242
+ featureName: feature.name,
227
243
  });
228
244
  continue;
229
245
  }
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);
246
+ const declared: unknown = 'handlers' in slice ? slice.handlers : undefined;
247
+ if (!isJournalDeclarations(declared))
248
+ throw badSlice(feature, 'handlers are not lists of projections, effects and taps');
249
+ for (const { name } of declared.projections) claimName('projection', owners, name, feature);
250
+ for (const { name } of declared.effects) claimName('effect', owners, name, feature);
251
+ for (const { name } of declared.taps) claimName('tap', owners, name, feature);
252
+ projections.push(...declared.projections);
253
+ effects.push(...declared.effects);
254
+ taps.push(...declared.taps);
244
255
  }
245
- return { projections, effects, taps };
256
+ return { declarations: { projections, effects, taps }, owners };
246
257
  }
247
258
 
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]) : [];
259
+ function journalPlugin<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
260
+ build: () => Journal<Meta, Tx, AppDb>,
261
+ ): JournalPlugin<Meta, Tx, AppDb> {
261
262
  const needs: { readonly db: Service<AppDb & RootDb & TransactionSource<Tx>> } = {
262
- ...Object.fromEntries(featureNeeds),
263
263
  db: service<AppDb & RootDb & TransactionSource<Tx>>(),
264
264
  };
265
-
266
265
  return plugin({
267
266
  name: 'journal',
268
267
  needs,
@@ -270,27 +269,97 @@ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<T
270
269
  ctx.registerService('journal', 'custom');
271
270
  ctx.declareProvides('journal');
272
271
  },
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
- },
272
+ boot: () => ({ journal: build() }),
273
+ });
274
+ }
275
+
276
+ /**
277
+ * The driven part, for `.use()`: publishes the journal as `services.journal`.
278
+ * It runs nothing in the background: serve `journalWorker()` for that.
279
+ *
280
+ * - `journal({ journal })`: the journal the app composed itself.
281
+ * - `journal({ j, tables })`: the journal composed from the `journalHandlers` slices of
282
+ * the features the app mounts. Effects and taps get their services from `journalWorker()`.
283
+ */
284
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
285
+ options: JournalDirectOptions<Meta, Tx, AppDb>,
286
+ ): JournalPlugin<Meta, Tx, AppDb>;
287
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
288
+ options: JournalFeatureOptions<Meta, Tx, AppDb>,
289
+ ): JournalDriven<Meta, Tx, AppDb>;
290
+ export function journal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>, AppDb extends RootDb>(
291
+ options: JournalDirectOptions<Meta, Tx, AppDb> | JournalFeatureOptions<Meta, Tx, AppDb>,
292
+ ): JournalPlugin<Meta, Tx, AppDb> | JournalDriven<Meta, Tx, AppDb> {
293
+ if ('journal' in options) {
294
+ const composed = options.journal;
295
+ return journalPlugin(() => composed);
296
+ }
297
+ const { j, tables } = options;
298
+ return driven(features =>
299
+ journalPlugin(() =>
300
+ // `'worker'`: the effects' services exist only once the features boot, after this journal.
301
+ j.compose({ tables, ...readDeclarations(features).declarations, services: 'worker' }),
302
+ ),
303
+ );
304
+ }
305
+
306
+ export type JournalWorkerOptions = {
307
+ readonly pollMs?: number;
308
+ readonly batchSize?: number;
309
+ readonly leaseSeconds?: number;
310
+ /** See `journal.worker`: required when the journal has taps, unless `taps: false`. */
311
+ readonly listen?: WakeListener;
312
+ readonly taps?: boolean;
313
+ };
314
+
315
+ /**
316
+ * What the worker needs of `services.journal`: any journal fits. The database type
317
+ * is checked where the journal is mounted (`journal(...)` needs the same `db`).
318
+ */
319
+ type WorkerHost = {
320
+ worker(db: RootDb, opts?: JournalWorkerOptions & { readonly services?: HandlerServices }): JournalWorker;
321
+ };
322
+
323
+ /** The services a feature's `journal` slice published at its boot. */
324
+ function sliceServices(ctx: object, feature: AnyFeature): Services {
325
+ const payload = sliceOf(ctx, feature).journal;
326
+ if (typeof payload !== 'object' || payload === null || !('services' in payload) || !isServices(payload.services))
327
+ throw badSlice(feature, 'boot payload holds no services; it was not made by journalHandlers');
328
+ return payload.services;
329
+ }
330
+
331
+ /**
332
+ * The driving part, for `.serve()`: one worker between `start` and `stop`, for the
333
+ * journal that `journal(...)` published. Each effect and tap of a feature gets the
334
+ * services of that feature. A process that serves no worker still writes: its jobs
335
+ * wait for a process that does.
336
+ */
337
+ export function journalWorker(
338
+ options: JournalWorkerOptions = {},
339
+ ): Driver<{ db: RootDb; journal: WorkerHost }, EmptyShape> {
340
+ return driver(features => {
341
+ let worker: JournalWorker | undefined;
342
+ return plugin({
343
+ name: 'journal-worker',
344
+ needs: { db: service<RootDb>(), journal: service<WorkerHost>() },
345
+ boot(ctx) {
346
+ const { owners } = readDeclarations(features);
347
+ const byFeature = new Map<AnyFeature, Services>();
348
+ // Every owner listed a `journal` slice, so each has a slice record.
349
+ for (const feature of new Set(owners.values())) byFeature.set(feature, sliceServices(ctx, feature));
350
+ const services: HandlerServices = ({ kind, name }) => {
351
+ const owner = owners.get(`${kind}:${name}`);
352
+ return (owner === undefined ? undefined : byFeature.get(owner)) ?? {};
353
+ };
354
+ // Built at boot, not at start: a missing service fails the boot.
355
+ worker = ctx.journal.worker(ctx.db, { ...options, services });
356
+ },
357
+ async start() {
358
+ await worker?.start();
359
+ },
360
+ async stop() {
361
+ await worker?.stop();
362
+ },
363
+ });
295
364
  });
296
365
  }