@zmdb/app 1.0.0-beta.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/LICENSE +674 -0
- package/README.md +27 -0
- package/dist/application.d.ts +45 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +186 -0
- package/dist/application.js.map +1 -0
- package/dist/commands/index.d.ts +31 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +338 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/cqrs/index.d.ts +47 -0
- package/dist/cqrs/index.d.ts.map +1 -0
- package/dist/cqrs/index.js +76 -0
- package/dist/cqrs/index.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +25 -0
- package/dist/data/index.js.map +1 -0
- package/dist/di/index.d.ts +44 -0
- package/dist/di/index.d.ts.map +1 -0
- package/dist/di/index.js +136 -0
- package/dist/di/index.js.map +1 -0
- package/dist/events/index.d.ts +46 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +181 -0
- package/dist/events/index.js.map +1 -0
- package/dist/health/index.d.ts +35 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +14 -0
- package/dist/health/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +21 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +52 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/messaging/index.d.ts +176 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +442 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/transport-kit.d.ts +22 -0
- package/dist/messaging/transport-kit.d.ts.map +1 -0
- package/dist/messaging/transport-kit.js +174 -0
- package/dist/messaging/transport-kit.js.map +1 -0
- package/dist/modules/index.d.ts +52 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +351 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/modules/lifecycle-instances.d.ts +4 -0
- package/dist/modules/lifecycle-instances.d.ts.map +1 -0
- package/dist/modules/lifecycle-instances.js +19 -0
- package/dist/modules/lifecycle-instances.js.map +1 -0
- package/dist/modules/runtime.d.ts +18 -0
- package/dist/modules/runtime.d.ts.map +1 -0
- package/dist/modules/runtime.js +8 -0
- package/dist/modules/runtime.js.map +1 -0
- package/dist/observability/index.d.ts +27 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +145 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/propagation.d.ts +10 -0
- package/dist/observability/propagation.d.ts.map +1 -0
- package/dist/observability/propagation.js +96 -0
- package/dist/observability/propagation.js.map +1 -0
- package/dist/observability/types.d.ts +66 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +10 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/polyfill.d.ts +2 -0
- package/dist/polyfill.d.ts.map +1 -0
- package/dist/polyfill.js +21 -0
- package/dist/polyfill.js.map +1 -0
- package/dist/state/fixtures.d.ts +10 -0
- package/dist/state/fixtures.d.ts.map +1 -0
- package/dist/state/fixtures.js +14 -0
- package/dist/state/fixtures.js.map +1 -0
- package/dist/state/index.d.ts +26 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +37 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state-transitions.d.ts +57 -0
- package/dist/state-transitions.d.ts.map +1 -0
- package/dist/state-transitions.js +31 -0
- package/dist/state-transitions.js.map +1 -0
- package/package.json +99 -0
- package/src/application.ts +243 -0
- package/src/commands/index.ts +447 -0
- package/src/cqrs/index.ts +124 -0
- package/src/data/index.ts +33 -0
- package/src/di/index.ts +179 -0
- package/src/events/index.ts +248 -0
- package/src/health/index.ts +54 -0
- package/src/index.ts +68 -0
- package/src/lifecycle.ts +62 -0
- package/src/messaging/index.ts +668 -0
- package/src/messaging/transport-kit.ts +234 -0
- package/src/modules/index.ts +476 -0
- package/src/modules/lifecycle-instances.ts +23 -0
- package/src/modules/runtime.ts +26 -0
- package/src/observability/index.ts +204 -0
- package/src/observability/propagation.ts +107 -0
- package/src/observability/types.ts +68 -0
- package/src/polyfill.ts +24 -0
- package/src/state/fixtures.ts +20 -0
- package/src/state/index.ts +57 -0
- package/src/state-transitions.ts +150 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// @zmdb/app — command applications (epic #497, spec ./SPEC.md).
|
|
2
|
+
// Commands use the same module compilation, container and lifecycle as
|
|
3
|
+
// createApplication, with argv validation at the terminal boundary and no
|
|
4
|
+
// runtime reflection or HTTP router.
|
|
5
|
+
|
|
6
|
+
import type { ParseArgsConfig } from 'node:util';
|
|
7
|
+
|
|
8
|
+
import { type JsonSchemaObject } from '@zmdb/schema/ir';
|
|
9
|
+
import { coerce } from '@zmdb/validator/advanced';
|
|
10
|
+
|
|
11
|
+
import { applicationBridgeOf, createApplication } from '../application.js';
|
|
12
|
+
import type { Container } from '../di/index.js';
|
|
13
|
+
import type { ModuleClass } from '../modules/index.js';
|
|
14
|
+
import '../polyfill.js';
|
|
15
|
+
|
|
16
|
+
/** Declaration recorded by `@Command`. */
|
|
17
|
+
export interface CommandDef<A> {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly description: string;
|
|
20
|
+
readonly args?: JsonSchemaObject;
|
|
21
|
+
readonly validate?: (raw: unknown) => A;
|
|
22
|
+
readonly positionals?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A zero-argument command class; dependencies arrive through `@Inject` fields. */
|
|
26
|
+
export type CommandClass<A> = abstract new () => { run(args: A): unknown };
|
|
27
|
+
|
|
28
|
+
/** A module graph driven from argv rather than an HTTP request. */
|
|
29
|
+
export interface CommandApp extends AsyncDisposable {
|
|
30
|
+
readonly container: Container;
|
|
31
|
+
run(argv?: readonly string[]): Promise<number>;
|
|
32
|
+
init(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const COMMAND = Symbol('zmdb.app.command');
|
|
36
|
+
|
|
37
|
+
interface CommandMetadata {
|
|
38
|
+
[COMMAND]?: CommandDef<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface MetadataCarrier {
|
|
42
|
+
readonly [Symbol.metadata]?: DecoratorMetadata | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RunnableCommand {
|
|
46
|
+
run(args: unknown): unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ArgumentProperty {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly option: string;
|
|
52
|
+
readonly parseType: 'boolean' | 'string';
|
|
53
|
+
readonly multiple: boolean;
|
|
54
|
+
readonly numeric: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RegisteredCommand {
|
|
58
|
+
readonly definition: CommandDef<unknown>;
|
|
59
|
+
readonly instance: RunnableCommand;
|
|
60
|
+
readonly properties: readonly ArgumentProperty[];
|
|
61
|
+
readonly propertyByName: ReadonlyMap<string, ArgumentProperty>;
|
|
62
|
+
readonly positionals: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PositionalParts {
|
|
66
|
+
readonly positionals: readonly string[];
|
|
67
|
+
readonly passthrough: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function commandView(metadata: DecoratorMetadata): CommandMetadata {
|
|
71
|
+
return metadata;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stage-3 class decorator: record one command declaration in `context.metadata`. */
|
|
75
|
+
export function Command<A>(
|
|
76
|
+
def: CommandDef<A>,
|
|
77
|
+
): <T extends CommandClass<A>>(target: T, context: ClassDecoratorContext<T>) => void {
|
|
78
|
+
return function <T extends CommandClass<A>>(_target: T, context: ClassDecoratorContext<T>): void {
|
|
79
|
+
commandView(context.metadata)[COMMAND] = def;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compile a root module through `createApplication`, then dispatch its declared
|
|
85
|
+
* commands. The returned app never calls `process.exit`; callers own
|
|
86
|
+
* `process.exitCode`.
|
|
87
|
+
*/
|
|
88
|
+
export function createCommandApp(rootModule: ModuleClass): CommandApp {
|
|
89
|
+
const application = createApplication(rootModule);
|
|
90
|
+
const commands = registerCommands(applicationBridgeOf(application).compiled.commands);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
container: application.container,
|
|
94
|
+
init: application.init,
|
|
95
|
+
run: (argv = process.argv.slice(2)) => runCommand(commands, argv),
|
|
96
|
+
[Symbol.asyncDispose]: application[Symbol.asyncDispose],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function registerCommands(instances: readonly object[]): readonly RegisteredCommand[] {
|
|
101
|
+
const commands: RegisteredCommand[] = [];
|
|
102
|
+
const names = new Set<string>();
|
|
103
|
+
for (const instance of instances) {
|
|
104
|
+
const definition = commandDefinitionOf(instance);
|
|
105
|
+
if (definition === undefined) {
|
|
106
|
+
throw new Error(`@zmdb/app: ${instance.constructor.name} is listed in commands but has no @Command declaration`);
|
|
107
|
+
}
|
|
108
|
+
if (!isRunnableCommand(instance)) {
|
|
109
|
+
throw new Error(`@zmdb/app: command "${definition.name}" has no run(args) method`);
|
|
110
|
+
}
|
|
111
|
+
if (definition.name.length === 0) {
|
|
112
|
+
throw new Error('@zmdb/app: a command name cannot be empty');
|
|
113
|
+
}
|
|
114
|
+
if (names.has(definition.name)) {
|
|
115
|
+
throw new Error(`@zmdb/app: duplicate command name "${definition.name}"`);
|
|
116
|
+
}
|
|
117
|
+
names.add(definition.name);
|
|
118
|
+
commands.push(prepareCommand(instance, definition));
|
|
119
|
+
}
|
|
120
|
+
return commands;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function commandDefinitionOf(instance: object): CommandDef<unknown> | undefined {
|
|
124
|
+
const carrier: MetadataCarrier = instance.constructor;
|
|
125
|
+
const metadata = carrier[Symbol.metadata];
|
|
126
|
+
if (metadata === undefined || metadata === null) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return commandView(metadata)[COMMAND];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isRunnableCommand(instance: object): instance is RunnableCommand {
|
|
133
|
+
return 'run' in instance && typeof instance.run === 'function';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function prepareCommand(instance: RunnableCommand, definition: CommandDef<unknown>): RegisteredCommand {
|
|
137
|
+
const positionals = [...(definition.positionals ?? [])];
|
|
138
|
+
const properties =
|
|
139
|
+
definition.args === undefined
|
|
140
|
+
? []
|
|
141
|
+
: Object.entries(definition.args.properties)
|
|
142
|
+
.map(([name, schema]) => argumentProperty(definition.name, name, schema))
|
|
143
|
+
.toSorted((left, right) => left.option.localeCompare(right.option));
|
|
144
|
+
const propertyByName = new Map(properties.map(property => [property.name, property]));
|
|
145
|
+
const seenPositionals = new Set<string>();
|
|
146
|
+
|
|
147
|
+
for (const positional of positionals) {
|
|
148
|
+
if (seenPositionals.has(positional)) {
|
|
149
|
+
throw new Error(`@zmdb/app: command "${definition.name}" repeats positional "${positional}"`);
|
|
150
|
+
}
|
|
151
|
+
seenPositionals.add(positional);
|
|
152
|
+
if (definition.args !== undefined && !propertyByName.has(positional)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`@zmdb/app: command "${definition.name}" names positional "${positional}" but its args type has no such property`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const optionNames = new Set<string>();
|
|
160
|
+
for (const property of properties) {
|
|
161
|
+
if (optionNames.has(property.option)) {
|
|
162
|
+
throw new Error(`@zmdb/app: command "${definition.name}" maps more than one property to --${property.option}`);
|
|
163
|
+
}
|
|
164
|
+
optionNames.add(property.option);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { definition, instance, properties, propertyByName, positionals };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function argumentProperty(command: string, name: string, schema: unknown): ArgumentProperty {
|
|
171
|
+
const types = schemaTypes(schema);
|
|
172
|
+
if (types.includes('object')) {
|
|
173
|
+
throw nestedArgument(command, name);
|
|
174
|
+
}
|
|
175
|
+
if (types.includes('array')) {
|
|
176
|
+
const items = schemaKeyword(schema, 'items');
|
|
177
|
+
const itemTypes = schemaTypes(items);
|
|
178
|
+
if (itemTypes.length === 0 || itemTypes.includes('object') || itemTypes.includes('array')) {
|
|
179
|
+
throw nestedArgument(command, name);
|
|
180
|
+
}
|
|
181
|
+
const itemType = scalarType(itemTypes);
|
|
182
|
+
return {
|
|
183
|
+
name,
|
|
184
|
+
option: kebabCase(name),
|
|
185
|
+
parseType: itemType === 'boolean' ? 'boolean' : 'string',
|
|
186
|
+
multiple: true,
|
|
187
|
+
numeric: itemType === 'number' || itemType === 'integer',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (schemaKeyword(schema, 'properties') !== undefined || schemaKeyword(schema, '$ref') !== undefined) {
|
|
191
|
+
throw nestedArgument(command, name);
|
|
192
|
+
}
|
|
193
|
+
const type = scalarType(types);
|
|
194
|
+
return {
|
|
195
|
+
name,
|
|
196
|
+
option: kebabCase(name),
|
|
197
|
+
parseType: type === 'boolean' ? 'boolean' : 'string',
|
|
198
|
+
multiple: false,
|
|
199
|
+
numeric: type === 'number' || type === 'integer',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function nestedArgument(command: string, property: string): Error {
|
|
204
|
+
return new Error(`@zmdb/app: command "${command}" argument "${property}" has a nested shape; argv must be flat`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function schemaTypes(schema: unknown): readonly string[] {
|
|
208
|
+
const type = schemaKeyword(schema, 'type');
|
|
209
|
+
if (typeof type === 'string') {
|
|
210
|
+
return [type];
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(type)) {
|
|
213
|
+
return type.filter(value => typeof value === 'string' && value !== 'null');
|
|
214
|
+
}
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function schemaKeyword(schema: unknown, keyword: string): unknown {
|
|
219
|
+
return typeof schema === 'object' && schema !== null ? Reflect.get(schema, keyword) : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scalarType(types: readonly string[]): string | undefined {
|
|
223
|
+
return types.find(type => type !== 'null');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function kebabCase(name: string): string {
|
|
227
|
+
return name.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runCommand(commands: readonly RegisteredCommand[], argv: readonly string[]): Promise<number> {
|
|
231
|
+
const selected = selectCommand(commands, argv);
|
|
232
|
+
if (selected.kind === 'list') {
|
|
233
|
+
process.stdout.write(commandList(commands));
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
if (selected.kind === 'unknown') {
|
|
237
|
+
process.stderr.write(`unknown command "${selected.name}"\n\n${commandList(commands)}`);
|
|
238
|
+
return 2;
|
|
239
|
+
}
|
|
240
|
+
if (selected.help) {
|
|
241
|
+
process.stdout.write(commandHelp(selected.command));
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let args: unknown;
|
|
246
|
+
try {
|
|
247
|
+
args = commandArguments(selected.command, selected.argv);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
process.stderr.write(`${lowerFirst(errorMessage(error))}\n\n${commandHelp(selected.command)}`);
|
|
250
|
+
return 2;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const result = await selected.command.instance.run(args);
|
|
255
|
+
return exitCode(result);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type CommandSelection =
|
|
263
|
+
| { readonly kind: 'list' }
|
|
264
|
+
| { readonly kind: 'unknown'; readonly name: string }
|
|
265
|
+
| {
|
|
266
|
+
readonly kind: 'command';
|
|
267
|
+
readonly command: RegisteredCommand;
|
|
268
|
+
readonly argv: readonly string[];
|
|
269
|
+
readonly help: boolean;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function selectCommand(commands: readonly RegisteredCommand[], argv: readonly string[]): CommandSelection {
|
|
273
|
+
if (commands.length === 1) {
|
|
274
|
+
const command = commands[0];
|
|
275
|
+
if (command === undefined) {
|
|
276
|
+
return { kind: 'list' };
|
|
277
|
+
}
|
|
278
|
+
const named = argv[0] === command.definition.name;
|
|
279
|
+
const commandArgv = named ? argv.slice(1) : argv;
|
|
280
|
+
return {
|
|
281
|
+
kind: 'command',
|
|
282
|
+
command,
|
|
283
|
+
argv: commandArgv,
|
|
284
|
+
help: requestsHelp(commandArgv),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const name = argv[0];
|
|
289
|
+
if (name === undefined || name === '--help') {
|
|
290
|
+
return { kind: 'list' };
|
|
291
|
+
}
|
|
292
|
+
const command = commands.find(candidate => candidate.definition.name === name);
|
|
293
|
+
if (command === undefined) {
|
|
294
|
+
return { kind: 'unknown', name };
|
|
295
|
+
}
|
|
296
|
+
const commandArgv = argv.slice(1);
|
|
297
|
+
return {
|
|
298
|
+
kind: 'command',
|
|
299
|
+
command,
|
|
300
|
+
argv: commandArgv,
|
|
301
|
+
help: requestsHelp(commandArgv),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function requestsHelp(argv: readonly string[]): boolean {
|
|
306
|
+
const terminator = argv.indexOf('--');
|
|
307
|
+
const beforeTerminator = terminator === -1 ? argv : argv.slice(0, terminator);
|
|
308
|
+
return beforeTerminator.includes('--help');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function parseCommandArgs<T extends ParseArgsConfig>(config: T) {
|
|
312
|
+
return process.getBuiltinModule('node:util').parseArgs(config);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function commandArguments(command: RegisteredCommand, argv: readonly string[]): unknown {
|
|
316
|
+
if (command.definition.args === undefined) {
|
|
317
|
+
const parsed = parseCommandArgs({
|
|
318
|
+
args: argv,
|
|
319
|
+
allowNegative: true,
|
|
320
|
+
allowPositionals: true,
|
|
321
|
+
strict: false,
|
|
322
|
+
tokens: true,
|
|
323
|
+
});
|
|
324
|
+
const parts = positionalParts(parsed.tokens);
|
|
325
|
+
const raw = {
|
|
326
|
+
values: parsed.values,
|
|
327
|
+
positionals: parts.positionals,
|
|
328
|
+
passthrough: parts.passthrough,
|
|
329
|
+
};
|
|
330
|
+
return command.definition.validate === undefined ? raw : command.definition.validate(raw);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const positionalNames = new Set(command.positionals);
|
|
334
|
+
const options: Record<string, { readonly type: 'boolean' | 'string'; readonly multiple?: boolean }> = {};
|
|
335
|
+
for (const property of command.properties) {
|
|
336
|
+
if (!positionalNames.has(property.name)) {
|
|
337
|
+
options[property.option] = {
|
|
338
|
+
type: property.parseType,
|
|
339
|
+
...(property.multiple ? { multiple: true } : {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const parsed = parseCommandArgs({
|
|
345
|
+
args: argv,
|
|
346
|
+
allowNegative: command.properties.some(property => property.parseType === 'boolean'),
|
|
347
|
+
allowPositionals: true,
|
|
348
|
+
options,
|
|
349
|
+
strict: true,
|
|
350
|
+
tokens: true,
|
|
351
|
+
});
|
|
352
|
+
const parts = positionalParts(parsed.tokens);
|
|
353
|
+
if (parts.positionals.length > command.positionals.length) {
|
|
354
|
+
const unexpected = parts.positionals[command.positionals.length] ?? '';
|
|
355
|
+
throw new Error(`unexpected positional argument "${unexpected}"`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const raw: Record<string, unknown> = {};
|
|
359
|
+
for (const property of command.properties) {
|
|
360
|
+
if (positionalNames.has(property.name)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const value = parsed.values[property.option];
|
|
364
|
+
if (value !== undefined) {
|
|
365
|
+
raw[property.name] = coerceArgument(property, value);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const [index, name] of command.positionals.entries()) {
|
|
369
|
+
const value = parts.positionals[index];
|
|
370
|
+
if (value === undefined) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const property = command.propertyByName.get(name);
|
|
374
|
+
raw[name] = property === undefined ? value : coerceArgument(property, value);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return command.definition.validate === undefined ? raw : command.definition.validate(raw);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function positionalParts(
|
|
381
|
+
tokens: readonly { readonly kind: string; readonly value?: string | undefined }[],
|
|
382
|
+
): PositionalParts {
|
|
383
|
+
const positionals: string[] = [];
|
|
384
|
+
const passthrough: string[] = [];
|
|
385
|
+
let afterTerminator = false;
|
|
386
|
+
for (const token of tokens) {
|
|
387
|
+
if (token.kind === 'option-terminator') {
|
|
388
|
+
afterTerminator = true;
|
|
389
|
+
} else if (token.kind === 'positional' && token.value !== undefined) {
|
|
390
|
+
(afterTerminator ? passthrough : positionals).push(token.value);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return { positionals, passthrough };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function coerceArgument(property: ArgumentProperty, value: string | boolean | readonly (string | boolean)[]): unknown {
|
|
397
|
+
if (property.multiple) {
|
|
398
|
+
const values = Array.isArray(value) ? value : [value];
|
|
399
|
+
return property.numeric ? values.map(item => coerce.number(item)) : values;
|
|
400
|
+
}
|
|
401
|
+
return property.numeric ? coerce.number(value) : value;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function commandList(commands: readonly RegisteredCommand[]): string {
|
|
405
|
+
const rows =
|
|
406
|
+
commands.length === 0
|
|
407
|
+
? [' (no commands registered)']
|
|
408
|
+
: commands
|
|
409
|
+
.toSorted((left, right) => left.definition.name.localeCompare(right.definition.name))
|
|
410
|
+
.map(command => ` ${command.definition.name} ${command.definition.description}`);
|
|
411
|
+
return `Usage: <command> [options]\n\nCommands:\n${rows.join('\n')}\n`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function commandHelp(command: RegisteredCommand): string {
|
|
415
|
+
const required = new Set(command.definition.args?.required ?? []);
|
|
416
|
+
const positionalNames = new Set(command.positionals);
|
|
417
|
+
const positionalUsage = command.positionals.map(name => (required.has(name) ? `<${name}>` : `[${name}]`)).join(' ');
|
|
418
|
+
const usage = `Usage: ${command.definition.name}${positionalUsage.length === 0 ? '' : ` ${positionalUsage}`}`;
|
|
419
|
+
const options = command.properties
|
|
420
|
+
.filter(property => !positionalNames.has(property.name))
|
|
421
|
+
.map(property => {
|
|
422
|
+
const value = property.parseType === 'boolean' ? '' : ` <value>${property.multiple ? '...' : ''}`;
|
|
423
|
+
const marker = required.has(property.name) ? ' (required)' : '';
|
|
424
|
+
return ` --${property.option}${value}${marker}`;
|
|
425
|
+
});
|
|
426
|
+
options.push(' --help');
|
|
427
|
+
return `${usage}\n\n${command.definition.description}\n\nOptions:\n${options.join('\n')}\n`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function exitCode(value: unknown): number {
|
|
431
|
+
if (typeof value === 'boolean') {
|
|
432
|
+
return value ? 0 : 1;
|
|
433
|
+
}
|
|
434
|
+
if (typeof value === 'number') {
|
|
435
|
+
const integer = Math.floor(value);
|
|
436
|
+
return Number.isNaN(integer) ? 1 : Math.max(0, Math.min(255, integer));
|
|
437
|
+
}
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function errorMessage(error: unknown): string {
|
|
442
|
+
return error instanceof Error && error.message.length > 0 ? error.message : String(error);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function lowerFirst(value: string): string {
|
|
446
|
+
return value.length === 0 ? value : `${value[0]?.toLowerCase()}${value.slice(1)}`;
|
|
447
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @zmdb/app — typed command bus (epic #591, spec ./SPEC.md).
|
|
2
|
+
// The mapped caller surface is built once from a complete handler map. Every
|
|
3
|
+
// command crosses the same validation, authorisation, transaction, and outcome
|
|
4
|
+
// observation boundary.
|
|
5
|
+
|
|
6
|
+
import { type TransactionContext } from '@zmdb/orm/transactions';
|
|
7
|
+
|
|
8
|
+
/** A command name mapped to its input and result types. */
|
|
9
|
+
export interface CommandMap {
|
|
10
|
+
readonly [command: string]: { readonly input: unknown; readonly result: unknown };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The caller-facing command object: one method per command name. */
|
|
14
|
+
export type CommandBus<M extends CommandMap> = {
|
|
15
|
+
readonly [K in keyof M]: (input: M[K]['input']) => Promise<M[K]['result']>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The complete application-owned handler object. */
|
|
19
|
+
export type CommandHandlers<M extends CommandMap> = {
|
|
20
|
+
readonly [K in keyof M]: (input: M[K]['input'], ctx: CommandRun) => Promise<M[K]['result']>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Context supplied to one command handler. */
|
|
24
|
+
export interface CommandRun {
|
|
25
|
+
readonly command: string;
|
|
26
|
+
readonly tx: TransactionContext | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Observation emitted after one command settles. */
|
|
30
|
+
export type CommandOutcome =
|
|
31
|
+
| { readonly command: string; readonly ok: true; readonly ms: number }
|
|
32
|
+
| { readonly command: string; readonly ok: false; readonly ms: number; readonly error: unknown };
|
|
33
|
+
|
|
34
|
+
/** The fixed command pipeline. Validation is deliberately total. */
|
|
35
|
+
export interface CommandBusOptions<M extends CommandMap> {
|
|
36
|
+
readonly validate: { readonly [K in keyof M]: (raw: unknown) => M[K]['input'] };
|
|
37
|
+
readonly authorise?: <K extends keyof M & string>(command: K, input: M[K]['input']) => Promise<void>;
|
|
38
|
+
readonly onCommand?: (run: CommandOutcome) => void;
|
|
39
|
+
readonly transaction?: (fn: (tx: TransactionContext) => Promise<unknown>) => Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function callable(record: object, key: string, kind: string): Function {
|
|
43
|
+
const value: unknown = Reflect.get(record, key);
|
|
44
|
+
if (typeof value !== 'function') {
|
|
45
|
+
throw new Error(`@zmdb/app: command "${key}" has no callable ${kind}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function observe(observer: ((run: CommandOutcome) => void) | undefined, outcome: CommandOutcome): void {
|
|
51
|
+
if (observer === undefined) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
observer(outcome);
|
|
56
|
+
} catch {
|
|
57
|
+
// Observation cannot replace either a successful result or the original
|
|
58
|
+
// command failure.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commandMethod<M extends CommandMap>(
|
|
63
|
+
command: string,
|
|
64
|
+
handler: Function,
|
|
65
|
+
validator: Function,
|
|
66
|
+
options: CommandBusOptions<M>,
|
|
67
|
+
): (raw: unknown) => Promise<unknown> {
|
|
68
|
+
return async (raw: unknown): Promise<unknown> => {
|
|
69
|
+
const started = options.onCommand === undefined ? undefined : globalThis.performance.now();
|
|
70
|
+
try {
|
|
71
|
+
const input: unknown = Reflect.apply(validator, undefined, [raw]);
|
|
72
|
+
if (options.authorise !== undefined) {
|
|
73
|
+
await Reflect.apply(options.authorise, undefined, [command, input]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const invoke = async (tx: TransactionContext | undefined): Promise<unknown> =>
|
|
77
|
+
Reflect.apply(handler, undefined, [input, { command, tx }]);
|
|
78
|
+
const result =
|
|
79
|
+
options.transaction === undefined ? await invoke(undefined) : await options.transaction(tx => invoke(tx));
|
|
80
|
+
|
|
81
|
+
if (started !== undefined) {
|
|
82
|
+
observe(options.onCommand, {
|
|
83
|
+
command,
|
|
84
|
+
ok: true,
|
|
85
|
+
ms: globalThis.performance.now() - started,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (started !== undefined) {
|
|
91
|
+
observe(options.onCommand, {
|
|
92
|
+
command,
|
|
93
|
+
ok: false,
|
|
94
|
+
ms: globalThis.performance.now() - started,
|
|
95
|
+
error,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build an app-owned command bus from a complete, compile-time-checked handler
|
|
105
|
+
* map. There is no registry, discovery pass, decorator, or global singleton.
|
|
106
|
+
*/
|
|
107
|
+
export function createCommandBus<M extends CommandMap>(
|
|
108
|
+
handlers: CommandHandlers<M>,
|
|
109
|
+
options: CommandBusOptions<M>,
|
|
110
|
+
): CommandBus<M> {
|
|
111
|
+
// boundary: Object.create(null) supplies the empty runtime carrier. The
|
|
112
|
+
// mapped input types prove the handler and validator key sets are complete,
|
|
113
|
+
// and the loop installs one method for every own command before returning.
|
|
114
|
+
const bus: CommandBus<M> = Object.create(null);
|
|
115
|
+
for (const command of Object.keys(handlers)) {
|
|
116
|
+
const handler = callable(handlers, command, 'handler');
|
|
117
|
+
const validator = callable(options.validate, command, 'validator');
|
|
118
|
+
const installed = Reflect.set(bus, command, commandMethod(command, handler, validator, options));
|
|
119
|
+
if (!installed) {
|
|
120
|
+
throw new Error(`@zmdb/app: could not install command "${command}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return bus;
|
|
124
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createLoaderScope, type BaseRepository, type LoaderScope } from '@zmdb/orm';
|
|
2
|
+
import { type DeclaredTable } from '@zmdb/schema';
|
|
3
|
+
|
|
4
|
+
import { createToken, type Token } from '../di/index.js';
|
|
5
|
+
|
|
6
|
+
/** Data access shared within one request, never across requests. */
|
|
7
|
+
export interface RequestData {
|
|
8
|
+
readonly loaders: LoaderScope;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const loadersByRequest = new WeakMap<RequestData, LoaderScope>();
|
|
12
|
+
const loadersProperty: PropertyDescriptor = {
|
|
13
|
+
configurable: true,
|
|
14
|
+
enumerable: true,
|
|
15
|
+
get(this: RequestData): LoaderScope {
|
|
16
|
+
let loaders = loadersByRequest.get(this);
|
|
17
|
+
if (loaders === undefined) {
|
|
18
|
+
loaders = createLoaderScope();
|
|
19
|
+
loadersByRequest.set(this, loaders);
|
|
20
|
+
}
|
|
21
|
+
return loaders;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Allocate a loader scope only when the request first uses data loading. */
|
|
26
|
+
export function createRequestData(): RequestData {
|
|
27
|
+
return Object.defineProperty({}, 'loaders', loadersProperty) as RequestData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A typed repository token shared by HTTP, jobs and command applications. */
|
|
31
|
+
export function repositoryToken<T extends DeclaredTable>(name: string): Token<BaseRepository<T>> {
|
|
32
|
+
return createToken<BaseRepository<T>>(name);
|
|
33
|
+
}
|