@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/README.md +18 -10
- package/dist/api.d.ts +40 -6
- package/dist/api.d.ts.map +1 -1
- package/dist/command.d.ts.map +1 -1
- package/dist/command.js +4 -0
- package/dist/command.js.map +1 -1
- package/dist/dot.d.ts +83 -41
- package/dist/dot.d.ts.map +1 -1
- package/dist/dot.js +111 -82
- package/dist/dot.js.map +1 -1
- package/dist/handlers.d.ts +22 -2
- package/dist/handlers.d.ts.map +1 -1
- package/dist/handlers.js +26 -12
- package/dist/handlers.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +12 -5
- package/dist/journal.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +12 -5
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +13 -7
- package/dist/worker.js.map +1 -1
- package/dist/write.js +1 -1
- package/dist/write.js.map +1 -1
- package/docs/agent-guide.md +23 -20
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/api.ts +46 -4
- package/src/command.ts +3 -0
- package/src/dot.ts +203 -134
- package/src/handlers.ts +41 -19
- package/src/index.ts +4 -0
- package/src/journal.ts +16 -6
- package/src/types.ts +9 -0
- package/src/worker.ts +45 -13
- package/src/write.ts +1 -1
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?: {
|
|
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
|
-
/**
|
|
465
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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: '
|
|
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
|
|
53
|
-
export type
|
|
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
|
|
61
|
-
* `
|
|
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
|
|
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
|
|
86
|
-
* (`
|
|
87
|
-
*
|
|
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
|
|
92
|
-
* each one in its own `needs`: the slice's `resolve` does not
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
value.projections.every(item => isHandler(item)) &&
|
|
188
|
+
isHandlerList(value.projections) &&
|
|
179
189
|
'effects' in value &&
|
|
180
|
-
|
|
181
|
-
value.effects.every(item => isHandler(item)) &&
|
|
190
|
+
isHandlerList(value.effects) &&
|
|
182
191
|
'taps' in value &&
|
|
183
|
-
|
|
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
|
-
*
|
|
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,
|
|
212
|
+
owners: Map<string, AnyFeature>,
|
|
196
213
|
name: string,
|
|
197
|
-
feature:
|
|
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 =
|
|
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
|
|
222
|
+
owners.set(`${kind}:${name}`, feature);
|
|
205
223
|
}
|
|
206
224
|
|
|
207
225
|
/**
|
|
208
|
-
* Read each feature's `journal` slice
|
|
209
|
-
*
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
|
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 "{
|
|
226
|
-
|
|
241
|
+
debugWorker.debug('[journal] feature "{featureName}" did not contribute a journal slice', {
|
|
242
|
+
featureName: feature.name,
|
|
227
243
|
});
|
|
228
244
|
continue;
|
|
229
245
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
}
|