@arki/journal 0.1.0 → 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/handlers.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  EffectContext,
7
7
  EffectHandlers,
8
8
  EffectSchedule,
9
+ HandlerServices,
9
10
  Need,
10
11
  Needed,
11
12
  Needs,
@@ -217,19 +218,32 @@ export function tapFor<Meta, AppDb>() {
217
218
  return tap;
218
219
  }
219
220
 
220
- /** Give one tap its services, as `bindEffect` does for an effect. */
221
- export function bindTap<Meta, AppDb>(tap: Tap<Meta, AppDb, Needs>, services: Services): Tap<Meta, AppDb> {
221
+ /**
222
+ * The value of each need of one handler, read from `services` by name. `services` may hold more.
223
+ * Throws when one is missing: without this check the handler would start, and fail on `undefined`.
224
+ */
225
+ export function givenServices(
226
+ kind: 'effect' | 'tap',
227
+ handler: { readonly name: string; readonly needs: Needs },
228
+ services: Services,
229
+ ): Services {
222
230
  const given: Record<string, unknown> = {};
223
- for (const key of Object.keys(tap.needs)) {
231
+ for (const key of Object.keys(handler.needs)) {
224
232
  if (!Object.hasOwn(services, key) || services[key] === undefined)
225
233
  throw new JournalError(
226
234
  'BAD_DEFINITION',
227
- 'tap',
228
- tap.name,
229
- `journal: tap "${tap.name}" needs "${key}", but no service with that name was given; pass it as j.compose({ services: { ${key} } }), or in a Dot app add it to the \`needs\` of the feature that lists this tap`,
235
+ kind,
236
+ handler.name,
237
+ `journal: ${kind} "${handler.name}" needs "${key}", but no service with that name was given; pass it as j.compose({ services: { ${key} } }), or in a Dot app add it to the \`needs\` of the feature that lists this ${kind}`,
230
238
  );
231
239
  given[key] = services[key];
232
240
  }
241
+ return given;
242
+ }
243
+
244
+ /** Give one tap its services, as `bindEffect` does for an effect. */
245
+ export function bindTap<Meta, AppDb>(tap: Tap<Meta, AppDb, Needs>, services: Services): Tap<Meta, AppDb> {
246
+ const given = givenServices('tap', tap, services);
233
247
  return { ...tap, needs: {}, run: (event, ctx) => tap.run(event, { ...given, ...ctx }) };
234
248
  }
235
249
 
@@ -237,26 +251,34 @@ export function bindTap<Meta, AppDb>(tap: Tap<Meta, AppDb, Needs>, services: Ser
237
251
  * Give one effect its services. The result needs nothing more, so the worker can run it.
238
252
  * `services` may hold more than this effect needs: each need is read by its name.
239
253
  * This is the only place a need meets its value. `j.compose` calls it with the app's
240
- * `services`; the Dot bridge calls it with the services of the feature that lists the effect.
254
+ * `services`; a worker calls it with the services it was given (`HandlerServices`).
241
255
  */
242
256
  export function bindEffect<Meta, AppDb, Run>(
243
257
  effect: Effect<Meta, AppDb, Run, Needs>,
244
258
  services: Services,
245
259
  ): Effect<Meta, AppDb, Run> {
246
- const given: Record<string, unknown> = {};
247
- for (const key of Object.keys(effect.needs)) {
248
- // Without this check the effect would start, and every job would fail on `undefined`.
249
- if (!Object.hasOwn(services, key) || services[key] === undefined)
250
- throw new JournalError(
251
- 'BAD_DEFINITION',
252
- 'effect',
253
- effect.name,
254
- `journal: effect "${effect.name}" needs "${key}", but no service with that name was given; pass it as j.compose({ services: { ${key} } }), or in a Dot app add it to the \`needs\` of the feature that lists this effect`,
255
- );
256
- given[key] = services[key];
257
- }
260
+ const given = givenServices('effect', effect, services);
258
261
  return withSchedule(
259
262
  { ...effect, needs: {}, run: (event, ctx) => effect.run(event, { ...given, ...ctx }) },
260
263
  effect.schedule,
261
264
  );
262
265
  }
266
+
267
+ /**
268
+ * The effects and taps as they run: each bound to its services from `services`.
269
+ * A handler `j.compose` already bound needs nothing, so it gets nothing. Throws on the first
270
+ * missing service, so a worker refuses to start instead of failing job after job.
271
+ */
272
+ export function bindHandlers<Meta, AppDb, Run>(
273
+ handlers: {
274
+ readonly effects: readonly Effect<Meta, AppDb, Run, Needs>[];
275
+ readonly taps: readonly Tap<Meta, AppDb, Needs>[];
276
+ },
277
+ services: HandlerServices | undefined,
278
+ ): { readonly effects: readonly Effect<Meta, AppDb, Run>[]; readonly taps: readonly Tap<Meta, AppDb>[] } {
279
+ const of = (kind: 'effect' | 'tap', name: string): Services => services?.({ kind, name }) ?? {};
280
+ return {
281
+ effects: handlers.effects.map(effect => bindEffect(effect, of('effect', effect.name))),
282
+ taps: handlers.taps.map(tap => bindTap(tap, of('tap', tap.name))),
283
+ };
284
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,9 @@ export { initJournal } from './journal.js';
2
2
  export { need } from './handlers.js';
3
3
  export type {
4
4
  Command,
5
+ CommandAuthorize,
5
6
  CommandContext,
7
+ CommandOptions,
6
8
  CommandRunner,
7
9
  Journal,
8
10
  JournalDefinitions,
@@ -66,6 +68,8 @@ export type {
66
68
  Row,
67
69
  Services,
68
70
  ServicesFor,
71
+ HandlerRef,
72
+ HandlerServices,
69
73
  TransactionSource,
70
74
  JournalWorker,
71
75
  StrandedJobs,
package/src/journal.ts CHANGED
@@ -20,6 +20,7 @@ import type {
20
20
  EntityTable,
21
21
  IdTable,
22
22
  KeyName,
23
+ Needs,
23
24
  Projection,
24
25
  RootDb,
25
26
  SingleKey,
@@ -30,7 +31,7 @@ import { createCommandRunner } from './command.js';
30
31
  import { defineEntity } from './entity.js';
31
32
  import { JournalError } from './errors.js';
32
33
  import { defineEvent } from './event.js';
33
- import { bindEffect, bindTap, defineProjection, effectFor, tapFor } from './handlers.js';
34
+ import { bindEffect, bindHandlers, bindTap, defineProjection, effectFor, tapFor } from './handlers.js';
34
35
  import { createHistory } from './history.js';
35
36
  import { createHookPoint } from './hooks.js';
36
37
  import {
@@ -114,13 +115,20 @@ function createInit<Meta extends z.ZodObject, Tx extends TransactionSource<Tx>,
114
115
  return {
115
116
  event: defineEvent,
116
117
  entity,
117
- command: (name, input, handler) => ({ name, input, handler }),
118
+ command: (name, input, handler, options) =>
119
+ options?.authorize === undefined
120
+ ? { name, input, handler }
121
+ : { name, input, handler, authorize: options.authorize },
118
122
  projection: (name, entity, on, opts) => defineProjection(name, entity, on, opts),
119
123
  effect: effectFor<MetaOut, AppDb, RootCommandRunner<Meta, Tx>>(),
120
124
  tap: tapFor<MetaOut, AppDb>(),
121
125
  compose: config => {
122
- const effects = (config.effects ?? []).map(effect => bindEffect(effect, config.services ?? {}));
123
- const taps = (config.taps ?? []).map(tap => bindTap(tap, config.services ?? {}));
126
+ // `'worker'`: the handlers keep their needs, and each worker or drain gives the services.
127
+ const services = config.services === 'worker' ? undefined : (config.services ?? {});
128
+ const effects = (config.effects ?? []).map(effect =>
129
+ services === undefined ? effect : bindEffect(effect, services),
130
+ );
131
+ const taps = (config.taps ?? []).map(tap => (services === undefined ? tap : bindTap(tap, services)));
124
132
  return createJournal<Meta, Tx, AppDb>(metaSchema, config.tables, config.projections ?? [], effects, taps);
125
133
  },
126
134
  };
@@ -143,8 +151,9 @@ function createJournal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx
143
151
  metaSchema: Meta,
144
152
  tables: JournalTables,
145
153
  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>[] = [],
154
+ /** An effect or a tap may still have needs (`services: 'worker'`): only the worker runs them, with its `services`. */
155
+ effects: readonly Effect<z.output<Meta>, AppDb, RootCommandRunner<Meta, Tx>, Needs>[],
156
+ taps: readonly Tap<z.output<Meta>, AppDb, Needs>[] = [],
148
157
  ): Journal<Meta, Tx, AppDb> {
149
158
  assertUniqueNames('tap', taps);
150
159
  const eventsSchema = getTableConfig(tables.events).schema;
@@ -196,6 +205,7 @@ function createJournal<Meta extends z.ZodObject, Tx extends TransactionSource<Tx
196
205
  drain: (db, opts) => journal.drain(db, opts),
197
206
  catchUp: (db, opts) => journal.catchUp(db, opts),
198
207
  dispatch,
208
+ checkServices: services => void bindHandlers({ effects, taps }, services),
199
209
  tapNames: taps.map(tap => tap.name),
200
210
  });
201
211
  const strandedJobs = createStrandedJobs({ tables, effectNames: effects.map(effect => effect.name) });
@@ -34,7 +34,6 @@ export function oneAtATime<T>(handle: object, what: string, work: () => Promise<
34
34
  );
35
35
  });
36
36
  }
37
- // eslint-disable-next-line drizzle/enforce-delete-with-where -- WeakMap#delete, not a Drizzle query
38
37
  const running = work().finally(() => inFlight.delete(handle));
39
38
  inFlight.set(handle, running);
40
39
  return running;
package/src/types.ts CHANGED
@@ -327,6 +327,15 @@ export type NoNeeds = Readonly<Record<never, Need<unknown>>>;
327
327
  export type Needed<N extends Needs> = { readonly [K in keyof N]: N[K] extends Need<infer T> ? T : never };
328
328
  /** Real services by name, as an app or a Dot feature hands them over. */
329
329
  export type Services = Readonly<Record<string, unknown>>;
330
+ /** Names one effect or one tap. Effect names and tap names are two separate sets. */
331
+ export type HandlerRef = { readonly kind: 'effect' | 'tap'; readonly name: string };
332
+ /**
333
+ * The services of each effect and tap, given where they run instead of where the journal
334
+ * is composed: `j.compose({ ..., services: 'worker' })`, then `journal.worker(db, { services })`.
335
+ * One function, so each handler can get its own services: two Dot features may use one need
336
+ * name for two different services.
337
+ */
338
+ export type HandlerServices = (handler: HandlerRef) => Services;
330
339
 
331
340
  type UnionToIntersection<U> = (U extends unknown ? (member: U) => void : never) extends (all: infer I) => void
332
341
  ? I
package/src/worker.ts CHANGED
@@ -15,7 +15,9 @@ import type {
15
15
  DbHandle,
16
16
  Effect,
17
17
  EffectHookContext,
18
+ HandlerServices,
18
19
  JournalWorker,
20
+ Needs,
19
21
  Projection,
20
22
  RootDb,
21
23
  StrandedJobs,
@@ -27,6 +29,7 @@ import type {
27
29
  import { debugDrain, debugWorker } from './debug.js';
28
30
  import { JournalError } from './errors.js';
29
31
  import { findEventDefinition } from './fold.js';
32
+ import { bindHandlers } from './handlers.js';
30
33
  import { leaseToken } from './id.js';
31
34
  import { assertRootDb } from './storage.js';
32
35
 
@@ -57,7 +60,8 @@ export type DrainDeps<Meta extends z.ZodObject, AppDb extends RootDb, Run> = {
57
60
  readonly tables: JournalTables;
58
61
  /** The effect hooks. Frozen by the first drain. */
59
62
  readonly hooks: HookPointInternal<EffectHookContext<z.output<Meta>>>;
60
- readonly effects: readonly Effect<z.output<Meta>, AppDb, Run>[];
63
+ /** As composed: an effect may still have needs. Each drain binds them from its `services`. */
64
+ readonly effects: readonly Effect<z.output<Meta>, AppDb, Run, Needs>[];
61
65
  readonly history: Pick<History<Meta>, 'decodeStoredEvent'>;
62
66
  /** Makes an effect's `ctx.run`: the journal's `run`, bound to the root database this drain was given and to the event the job is for. */
63
67
  readonly runOn: (db: AppDb & RootDb, cause: { readonly seq: number; readonly correlationId: string | null }) => Run;
@@ -66,9 +70,7 @@ export type DrainDeps<Meta extends z.ZodObject, AppDb extends RootDb, Run> = {
66
70
  export function createDrain<Meta extends z.ZodObject, AppDb extends RootDb, Run>(deps: DrainDeps<Meta, AppDb, Run>) {
67
71
  const { events: eventsTable, jobs: jobsTable } = deps.tables;
68
72
  const { decodeStoredEvent } = deps.history;
69
- const effectsByName = new Map(deps.effects.map(effect => [effect.name, effect]));
70
-
71
- const known = [...effectsByName.keys()];
73
+ const known = deps.effects.map(effect => effect.name);
72
74
 
73
75
  // Every time here is database time, so a worker with a skewed clock
74
76
  // cannot take a live lease or push a retry into the far future.
@@ -149,7 +151,13 @@ export function createDrain<Meta extends z.ZodObject, AppDb extends RootDb, Run>
149
151
  };
150
152
  };
151
153
 
152
- const runJob = async (db: AppDb & RootDb, job: JobRow, signal: AbortSignal, leaseSeconds: number): Promise<void> => {
154
+ const runJob = async (
155
+ db: AppDb & RootDb,
156
+ job: JobRow,
157
+ signal: AbortSignal,
158
+ leaseSeconds: number,
159
+ effectsByName: ReadonlyMap<string, Effect<z.output<Meta>, AppDb, Run>>,
160
+ ): Promise<void> => {
153
161
  const effect = effectsByName.get(job.effect);
154
162
  const lost = new AbortController();
155
163
  const stopRenewing = renewWhile(db, job, leaseSeconds, lost);
@@ -194,9 +202,17 @@ export function createDrain<Meta extends z.ZodObject, AppDb extends RootDb, Run>
194
202
 
195
203
  return async (
196
204
  db: AppDb & RootDb,
197
- opts?: { readonly batchSize?: number; readonly signal?: AbortSignal; readonly leaseSeconds?: number },
205
+ opts?: {
206
+ readonly batchSize?: number;
207
+ readonly signal?: AbortSignal;
208
+ readonly leaseSeconds?: number;
209
+ readonly services?: HandlerServices;
210
+ },
198
211
  ): Promise<number> => {
199
212
  assertRootDb(db, 'drain');
213
+ // Before any claim: a missing service refuses the drain, not one job after another.
214
+ const { effects } = bindHandlers({ effects: deps.effects, taps: [] }, opts?.services);
215
+ const effectsByName = new Map(effects.map(effect => [effect.name, effect]));
200
216
  deps.hooks.freeze();
201
217
  const batchSize = opts?.batchSize ?? 20;
202
218
  const leaseSeconds = opts?.leaseSeconds ?? DEFAULT_LEASE_SECONDS;
@@ -213,7 +229,7 @@ export function createDrain<Meta extends z.ZodObject, AppDb extends RootDb, Run>
213
229
  const job = await claimOne(db, leaseSeconds);
214
230
  if (job === undefined) break;
215
231
  claimed += 1;
216
- await runJob(db, job, signal, leaseSeconds);
232
+ await runJob(db, job, signal, leaseSeconds, effectsByName);
217
233
  }
218
234
  return claimed;
219
235
  };
@@ -293,7 +309,12 @@ export type WorkerDeps<AppDb extends RootDb> = {
293
309
  /** Runs one batch. The journal passes its own `drain` method, looked up on each call. */
294
310
  readonly drain: (
295
311
  db: AppDb & RootDb,
296
- opts?: { readonly batchSize?: number; readonly signal?: AbortSignal; readonly leaseSeconds?: number },
312
+ opts?: {
313
+ readonly batchSize?: number;
314
+ readonly signal?: AbortSignal;
315
+ readonly leaseSeconds?: number;
316
+ readonly services?: HandlerServices;
317
+ },
297
318
  ) => Promise<number>;
298
319
  /** One batch per async projection. Returns how many events were scanned. */
299
320
  readonly catchUp: (
@@ -301,7 +322,9 @@ export type WorkerDeps<AppDb extends RootDb> = {
301
322
  opts?: { readonly batchSize?: number },
302
323
  ) => Promise<{ readonly scanned: number }>;
303
324
  /** Fire the taps for one wake payload. */
304
- readonly dispatch: (db: AppDb & RootDb, payload: string) => Promise<void>;
325
+ readonly dispatch: (db: AppDb & RootDb, payload: string, services: HandlerServices | undefined) => Promise<void>;
326
+ /** Throws when an effect or a tap is missing a service in `services`. */
327
+ readonly checkServices: (services: HandlerServices | undefined) => void;
305
328
  readonly tapNames: readonly string[];
306
329
  };
307
330
 
@@ -427,7 +450,8 @@ export function createCatchUp<Meta extends z.ZodObject, Tx extends TransactionSo
427
450
  /** What tap dispatch needs from the journal. */
428
451
  export type TapDispatchDeps<Meta extends z.ZodObject, AppDb extends RootDb> = {
429
452
  readonly tables: Pick<JournalTables, 'events'>;
430
- readonly taps: readonly Tap<z.output<Meta>, AppDb>[];
453
+ /** As composed: a tap may still have needs. Each dispatch binds them from its `services`. */
454
+ readonly taps: readonly Tap<z.output<Meta>, AppDb, Needs>[];
431
455
  readonly history: Pick<History<Meta>, 'decodeStoredEvent'>;
432
456
  };
433
457
 
@@ -451,10 +475,15 @@ export function createTapDispatch<Meta extends z.ZodObject, AppDb extends RootDb
451
475
  const types = [...new Set(deps.taps.flatMap(tap => [...tap.types]))];
452
476
 
453
477
  /** Load the committed events in the range that some tap wants, and run each matching tap. Errors are logged, never thrown. */
454
- const dispatch = async (db: AppDb & RootDb, payload: string): Promise<void> => {
478
+ const dispatch = async (
479
+ db: AppDb & RootDb,
480
+ payload: string,
481
+ services: HandlerServices | undefined,
482
+ ): Promise<void> => {
455
483
  if (deps.taps.length === 0) return;
456
484
  const range = parseRange(payload);
457
485
  if (range === undefined) return;
486
+ const { taps } = bindHandlers({ effects: [], taps: deps.taps }, services);
458
487
  const rows = await db
459
488
  .select()
460
489
  .from(eventsTable)
@@ -468,7 +497,7 @@ export function createTapDispatch<Meta extends z.ZodObject, AppDb extends RootDb
468
497
  )
469
498
  .orderBy(asc(eventsTable.seq));
470
499
  for (const row of rows) {
471
- for (const tap of deps.taps) {
500
+ for (const tap of taps) {
472
501
  if (!tap.types.has(row.type) || !tap.entities.some(entity => entity.kind === row.kind)) continue;
473
502
  try {
474
503
  const event = deps.history.decodeStoredEvent(
@@ -507,9 +536,11 @@ export function createWorkers<AppDb extends RootDb>(deps: WorkerDeps<AppDb>) {
507
536
  readonly leaseSeconds?: number;
508
537
  readonly listen?: WakeListener;
509
538
  readonly taps?: boolean;
539
+ readonly services?: HandlerServices;
510
540
  },
511
541
  ): JournalWorker => {
512
542
  assertRootDb(db, 'worker');
543
+ deps.checkServices(opts?.services);
513
544
  if (deps.tapNames.length > 0 && opts?.listen === undefined && opts?.taps !== false)
514
545
  throw new Error(
515
546
  `journal: this journal has taps (${deps.tapNames.join(', ')}) but the worker has no \`listen\`; taps fire only from the wake channel. Wire LISTEN on journal.wakeChannel, or pass { taps: false } for a worker that runs jobs only`,
@@ -551,6 +582,7 @@ export function createWorkers<AppDb extends RootDb>(deps: WorkerDeps<AppDb>) {
551
582
  batchSize: opts?.batchSize,
552
583
  signal: stopping.signal,
553
584
  leaseSeconds: opts?.leaseSeconds,
585
+ services: opts?.services,
554
586
  });
555
587
  if (isRunning()) {
556
588
  const caught = await deps.catchUp(db);
@@ -577,7 +609,7 @@ export function createWorkers<AppDb extends RootDb>(deps: WorkerDeps<AppDb>) {
577
609
  unlisten = await opts.listen(payload => {
578
610
  wake();
579
611
  if (opts.taps === false) return;
580
- dispatching = dispatching.then(() => deps.dispatch(db, payload)).catch(quiet);
612
+ dispatching = dispatching.then(() => deps.dispatch(db, payload, opts.services)).catch(quiet);
581
613
  });
582
614
  }
583
615
  debugWorker.debug('start (poll {pollMs}ms{value2})', {
@@ -589,7 +621,6 @@ export function createWorkers<AppDb extends RootDb>(deps: WorkerDeps<AppDb>) {
589
621
  async stop() {
590
622
  running = false;
591
623
  stopping.abort();
592
- // eslint-disable-next-line drizzle/enforce-delete-with-where -- Set#delete, not a Drizzle query
593
624
  wakers.delete(wake);
594
625
  endSleep?.();
595
626
  await unlisten?.();
package/src/write.ts CHANGED
@@ -87,7 +87,7 @@ function dueAt(
87
87
 
88
88
  /** Insert order is the version order. `RETURNING` does not promise it, so restore it. */
89
89
  export function sortedByVersion<Row extends { readonly version: number }>(rows: readonly Row[]): Row[] {
90
- return rows.toSorted((a, b) => a.version - b.version);
90
+ return [...rows].sort((a, b) => a.version - b.version);
91
91
  }
92
92
 
93
93
  /** What a write needs from the journal. */