@arki/dot 0.2.0 → 0.4.0
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 +86 -86
- package/dist/cli/discover.js +2 -2
- package/dist/cli/discover.js.map +1 -1
- package/dist/cli/error-codes.d.ts +8 -0
- package/dist/cli/error-codes.d.ts.map +1 -1
- package/dist/cli/error-codes.js +12 -0
- package/dist/cli/error-codes.js.map +1 -1
- package/dist/cli/index.d.ts +11 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +66 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/new.d.ts +1 -1
- package/dist/cli/new.js +1 -1
- package/dist/cli/render-doctor.d.ts +1 -1
- package/dist/cli/render-doctor.js +23 -23
- package/dist/cli/render-doctor.js.map +1 -1
- package/dist/cli/render-explain.d.ts.map +1 -1
- package/dist/cli/render-explain.js +33 -19
- package/dist/cli/render-explain.js.map +1 -1
- package/dist/cli/render-graph.d.ts +3 -3
- package/dist/cli/render-graph.js +12 -12
- package/dist/cli/render-graph.js.map +1 -1
- package/dist/cli/render-openapi.d.ts +38 -0
- package/dist/cli/render-openapi.d.ts.map +1 -0
- package/dist/cli/render-openapi.js +131 -0
- package/dist/cli/render-openapi.js.map +1 -0
- package/dist/cli/render-projection.d.ts +29 -0
- package/dist/cli/render-projection.d.ts.map +1 -0
- package/dist/cli/render-projection.js +202 -0
- package/dist/cli/render-projection.js.map +1 -0
- package/dist/define-app.d.ts +49 -17
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +16 -9
- package/dist/define-app.js.map +1 -1
- package/dist/diagnostics.d.ts +19 -15
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +2 -2
- package/dist/index.d.ts +15 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/init-pips.d.ts +45 -0
- package/dist/init-pips.d.ts.map +1 -0
- package/dist/init-pips.js +49 -0
- package/dist/init-pips.js.map +1 -0
- package/dist/init-plugins.d.ts +45 -0
- package/dist/init-plugins.d.ts.map +1 -0
- package/dist/init-plugins.js +49 -0
- package/dist/init-plugins.js.map +1 -0
- package/dist/kernel/app-instance.d.ts +5 -5
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +375 -244
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/kernel/otel.d.ts +19 -19
- package/dist/kernel/otel.d.ts.map +1 -1
- package/dist/kernel/otel.js +22 -22
- package/dist/kernel/otel.js.map +1 -1
- package/dist/lifecycle-observer.d.ts +10 -10
- package/dist/lifecycle-observer.d.ts.map +1 -1
- package/dist/lifecycle-observer.js +1 -1
- package/dist/lifecycle.d.ts +18 -14
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +13 -9
- package/dist/lifecycle.js.map +1 -1
- package/dist/manifest.d.ts +70 -29
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +74 -5
- package/dist/manifest.js.map +1 -1
- package/dist/pip-contract.d.ts +40 -10
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +1 -25
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +7 -4
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js +2 -0
- package/dist/pip.js.map +1 -1
- package/dist/plugin-contract.d.ts +335 -143
- package/dist/plugin-contract.d.ts.map +1 -1
- package/dist/plugin-contract.js +201 -36
- package/dist/plugin-contract.js.map +1 -1
- package/dist/plugin.d.ts +18 -15
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +11 -11
- package/dist/plugin.js.map +1 -1
- package/dist/test-harness.d.ts +31 -31
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +29 -28
- package/dist/test-harness.js.map +1 -1
- package/dist/timeline.d.ts +2 -2
- package/dist/timeline.js +5 -5
- package/dist/timeline.js.map +1 -1
- package/package.json +9 -9
- package/src/cli/discover.ts +2 -2
- package/src/cli/error-codes.ts +12 -0
- package/src/cli/index.ts +93 -15
- package/src/cli/new.ts +1 -1
- package/src/cli/render-doctor.ts +21 -21
- package/src/cli/render-explain.ts +37 -19
- package/src/cli/render-graph.ts +12 -12
- package/src/cli/render-projection.ts +239 -0
- package/src/define-app.ts +85 -22
- package/src/diagnostics.ts +19 -15
- package/src/index.ts +38 -20
- package/src/init-plugins.ts +142 -0
- package/src/kernel/app-instance.ts +428 -282
- package/src/kernel/otel.ts +27 -27
- package/src/lifecycle-observer.ts +10 -10
- package/src/lifecycle.ts +19 -15
- package/src/manifest.ts +128 -31
- package/src/{pip-contract.ts → plugin-contract.ts} +83 -44
- package/src/{pip.ts → plugin.ts} +38 -18
- package/src/test-harness.ts +52 -51
- package/src/timeline.ts +6 -6
- package/templates/app-minimal/AGENTS.md.tmpl +9 -9
- package/templates/app-minimal/README.md.tmpl +1 -1
- package/templates/app-minimal/tests/boot.test.ts.tmpl +1 -1
|
@@ -70,14 +70,14 @@ function renderTextManifest(manifest: DotAppManifest): string {
|
|
|
70
70
|
lines.push('='.repeat(title.length));
|
|
71
71
|
lines.push('');
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
lines.push(`
|
|
75
|
-
if (manifest.
|
|
73
|
+
// Plugins
|
|
74
|
+
lines.push(`Plugins (${manifest.plugins.length})`);
|
|
75
|
+
if (manifest.plugins.length === 0) {
|
|
76
76
|
lines.push(' (none)');
|
|
77
77
|
} else {
|
|
78
|
-
const widthName = Math.max(6, ...manifest.
|
|
78
|
+
const widthName = Math.max(6, ...manifest.plugins.map(p => p.name.length));
|
|
79
79
|
lines.push(` ${pad('NAME', widthName)} VERSION DEPENDENCIES`);
|
|
80
|
-
for (const p of manifest.
|
|
80
|
+
for (const p of manifest.plugins) {
|
|
81
81
|
const version = p.version ?? '-';
|
|
82
82
|
const deps = p.dependencies.length > 0 ? p.dependencies.join(', ') : '-';
|
|
83
83
|
lines.push(` ${pad(p.name, widthName)} ${pad(version, 7)} ${deps}`);
|
|
@@ -94,23 +94,41 @@ function renderTextManifest(manifest: DotAppManifest): string {
|
|
|
94
94
|
const widthKind = Math.max(4, ...manifest.services.map(s => s.kind.length));
|
|
95
95
|
lines.push(` ${pad('NAME', widthName)} ${pad('KIND', widthKind)} PLUGIN`);
|
|
96
96
|
for (const s of manifest.services) {
|
|
97
|
-
lines.push(` ${pad(s.name, widthName)} ${pad(s.kind, widthKind)} ${s.
|
|
97
|
+
lines.push(` ${pad(s.name, widthName)} ${pad(s.kind, widthKind)} ${s.plugin}`);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
lines.push('');
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
lines.push(`
|
|
104
|
-
if (manifest.
|
|
102
|
+
// Actions
|
|
103
|
+
lines.push(`Actions (${manifest.actions.length})`);
|
|
104
|
+
if (manifest.actions.length === 0) {
|
|
105
105
|
lines.push(' (none)');
|
|
106
106
|
} else {
|
|
107
|
-
const widthId = Math.max(2, ...manifest.
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
const widthId = Math.max(2, ...manifest.actions.map(action => action.id.length));
|
|
108
|
+
const widthBinding = Math.max(7, ...manifest.actions.map(action => action.binding.length));
|
|
109
|
+
const widthAddress = Math.max(7, ...manifest.actions.map(action => (action.address ?? '-').length));
|
|
110
|
+
lines.push(` ${pad('ID', widthId)} ${pad('BINDING', widthBinding)} DIR ${pad('ADDRESS', widthAddress)} PLUGIN`);
|
|
111
|
+
for (const action of manifest.actions) {
|
|
112
|
+
lines.push(
|
|
113
|
+
` ${pad(action.id, widthId)} ${pad(action.binding, widthBinding)} ${pad(action.direction, 3)} ${pad(action.address ?? '-', widthAddress)} ${action.plugin}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
lines.push('');
|
|
118
|
+
|
|
119
|
+
// Projections
|
|
120
|
+
lines.push(`Projections (${manifest.projections.length})`);
|
|
121
|
+
if (manifest.projections.length === 0) {
|
|
122
|
+
lines.push(' (none)');
|
|
123
|
+
} else {
|
|
124
|
+
const widthFormat = Math.max(6, ...manifest.projections.map(projection => projection.format.length));
|
|
125
|
+
const widthBinding = Math.max(7, ...manifest.projections.map(projection => projection.binding.length));
|
|
126
|
+
const widthModule = Math.max(6, ...manifest.projections.map(projection => projection.module.length));
|
|
127
|
+
lines.push(` ${pad('FORMAT', widthFormat)} ${pad('BINDING', widthBinding)} ${pad('MODULE', widthModule)} PLUGIN`);
|
|
128
|
+
for (const projection of manifest.projections) {
|
|
129
|
+
lines.push(
|
|
130
|
+
` ${pad(projection.format, widthFormat)} ${pad(projection.binding, widthBinding)} ${pad(projection.module, widthModule)} ${projection.plugin}`,
|
|
131
|
+
);
|
|
114
132
|
}
|
|
115
133
|
}
|
|
116
134
|
lines.push('');
|
|
@@ -131,11 +149,11 @@ function renderTextManifest(manifest: DotAppManifest): string {
|
|
|
131
149
|
if (manifest.lifecycle.length === 0) {
|
|
132
150
|
lines.push(' (none)');
|
|
133
151
|
} else {
|
|
134
|
-
const
|
|
135
|
-
lines.push(` ${pad('PLUGIN',
|
|
152
|
+
const widthPlugin = Math.max(6, ...manifest.lifecycle.map(l => l.plugin.length));
|
|
153
|
+
lines.push(` ${pad('PLUGIN', widthPlugin)} HOOKS`);
|
|
136
154
|
for (const l of manifest.lifecycle) {
|
|
137
155
|
const hooks = l.hooks.length > 0 ? l.hooks.join(', ') : '-';
|
|
138
|
-
lines.push(` ${pad(l.
|
|
156
|
+
lines.push(` ${pad(l.plugin, widthPlugin)} ${hooks}`);
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
159
|
lines.push('');
|
package/src/cli/render-graph.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Renderer for `dot explain --graph` / `dot doctor --graph`.
|
|
3
3
|
*
|
|
4
|
-
* Emits the app's
|
|
4
|
+
* Emits the app's plugin graph as [Mermaid](https://mermaid.js.org) `flowchart`
|
|
5
5
|
* source — paste-able into GitHub markdown, docs, and mermaid.live.
|
|
6
6
|
*
|
|
7
|
-
* The nodes are the
|
|
7
|
+
* The nodes are the plugins in declaration order (which IS boot order in v2 —
|
|
8
8
|
* the numbering makes that visible); the edges are the manifest's
|
|
9
|
-
* **observed** dependency edges, recorded by the kernel when a
|
|
9
|
+
* **observed** dependency edges, recorded by the kernel when a plugin's need
|
|
10
10
|
* was satisfied during boot. `explain` never boots, so its graph shows
|
|
11
11
|
* declaration order with whatever edges configure-time metadata declared;
|
|
12
12
|
* `doctor` boots, so its graph shows the real wiring.
|
|
@@ -22,7 +22,7 @@ export type GraphData = {
|
|
|
22
22
|
source: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
/** Escape a
|
|
25
|
+
/** Escape a plugin name for use inside a quoted Mermaid node label. */
|
|
26
26
|
function escapeLabel(name: string): string {
|
|
27
27
|
return name.replaceAll('"', '#quot;');
|
|
28
28
|
}
|
|
@@ -33,20 +33,20 @@ function escapeLabel(name: string): string {
|
|
|
33
33
|
*/
|
|
34
34
|
export function buildMermaidGraph(manifest: DotAppManifest): string {
|
|
35
35
|
const lines: string[] = ['flowchart TD'];
|
|
36
|
-
const
|
|
36
|
+
const idByPlugin = new Map<string, string>();
|
|
37
37
|
|
|
38
|
-
for (const [index,
|
|
38
|
+
for (const [index, plugin] of manifest.plugins.entries()) {
|
|
39
39
|
const id = `p${index.toString()}`;
|
|
40
|
-
|
|
40
|
+
idByPlugin.set(plugin.name, id);
|
|
41
41
|
const order = (index + 1).toString();
|
|
42
|
-
const version =
|
|
43
|
-
lines.push(` ${id}["${order} · ${escapeLabel(
|
|
42
|
+
const version = plugin.version === undefined ? '' : `@${plugin.version}`;
|
|
43
|
+
lines.push(` ${id}["${order} · ${escapeLabel(plugin.name)}${escapeLabel(version)}"]`);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
for (const edge of manifest.dependencies) {
|
|
47
|
-
const from =
|
|
48
|
-
const to =
|
|
49
|
-
// Edges referencing unknown
|
|
47
|
+
const from = idByPlugin.get(edge.from);
|
|
48
|
+
const to = idByPlugin.get(edge.to);
|
|
49
|
+
// Edges referencing unknown plugins would be a kernel bug — skip rather
|
|
50
50
|
// than emit invalid Mermaid.
|
|
51
51
|
if (from === undefined || to === undefined) continue;
|
|
52
52
|
lines.push(` ${from} -->|${edge.kind}| ${to}`);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `dot explain --as <format>`.
|
|
3
|
+
*
|
|
4
|
+
* The kernel owns selection, app-file-relative module resolution, import, and
|
|
5
|
+
* envelope printing. The adapter owns the projection module and document
|
|
6
|
+
* vocabulary.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { dirname, isAbsolute } from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import type { DotAppManifest, JsonValue, ProjectionManifest } from '../manifest.js';
|
|
14
|
+
import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
|
|
15
|
+
import { DotCliError, DotCliErrorCode } from './error-codes.js';
|
|
16
|
+
|
|
17
|
+
export type ProjectionData = {
|
|
18
|
+
format: string;
|
|
19
|
+
document: JsonValue;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ProjectionModule = {
|
|
23
|
+
project(manifest: DotAppManifest): unknown | Promise<unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ProjectionSource = {
|
|
27
|
+
manifest: DotAppManifest;
|
|
28
|
+
command: 'explain';
|
|
29
|
+
format: string;
|
|
30
|
+
/** Absolute app file path; projection specifiers resolve relative to it. */
|
|
31
|
+
appFilePath: string;
|
|
32
|
+
/** Per-invocation debug override for the selected projection module. */
|
|
33
|
+
module?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const defaultOut = (line: string) => {
|
|
37
|
+
process.stdout.write(line);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function nowIso(opts: RenderOptions): string {
|
|
41
|
+
const factory = opts.now ?? (() => new Date());
|
|
42
|
+
return factory().toISOString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isJsonPrimitive(value: unknown): value is string | number | boolean | null {
|
|
46
|
+
return value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
51
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
52
|
+
return prototype === Object.prototype || prototype === null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
56
|
+
if (isJsonPrimitive(value)) return typeof value !== 'number' || Number.isFinite(value);
|
|
57
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
58
|
+
if (!isPlainObject(value)) return false;
|
|
59
|
+
return Object.values(value).every(isJsonValue);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function jsonDeepEqual(left: unknown, right: unknown): boolean {
|
|
63
|
+
if (isJsonPrimitive(left) || isJsonPrimitive(right)) return Object.is(left, right);
|
|
64
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
65
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
66
|
+
return left.every((value, index) => jsonDeepEqual(value, right[index]));
|
|
67
|
+
}
|
|
68
|
+
if (!isPlainObject(left) || !isPlainObject(right)) return false;
|
|
69
|
+
const leftEntries = Object.entries(left);
|
|
70
|
+
const rightEntries = Object.entries(right);
|
|
71
|
+
if (leftEntries.length !== rightEntries.length) return false;
|
|
72
|
+
for (const [key, value] of leftEntries) {
|
|
73
|
+
if (!Object.hasOwn(right, key) || !jsonDeepEqual(value, right[key])) return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toProjectionJsonValue(value: unknown): JsonValue {
|
|
79
|
+
let serialized: string;
|
|
80
|
+
try {
|
|
81
|
+
serialized = JSON.stringify(value);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new TypeError('Projection output must be JSON-serializable.', { cause: error });
|
|
84
|
+
}
|
|
85
|
+
if (serialized === undefined) {
|
|
86
|
+
throw new TypeError('Projection output must be JSON-serializable.');
|
|
87
|
+
}
|
|
88
|
+
const parsed = JSON.parse(serialized) as unknown;
|
|
89
|
+
if (!isJsonValue(parsed) || !jsonDeepEqual(value, parsed)) {
|
|
90
|
+
throw new TypeError('Projection output must survive a JSON round trip without lossy coercions.');
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selectProjection(manifest: DotAppManifest, format: string): ProjectionManifest {
|
|
96
|
+
const projections = manifest.projections.filter(projection => projection.format === format);
|
|
97
|
+
if (projections.length === 0) {
|
|
98
|
+
const formats = [...new Set(manifest.projections.map(projection => projection.format))];
|
|
99
|
+
throw new DotCliError({
|
|
100
|
+
code: DotCliErrorCode.ProjectionNotFound,
|
|
101
|
+
message: `No projection is registered for format "${format}".`,
|
|
102
|
+
remediation:
|
|
103
|
+
formats.length === 0
|
|
104
|
+
? 'Register an adapter that contributes this projection format, or remove --as.'
|
|
105
|
+
: `Use one of the registered formats (${formats.join(', ')}), or mount the adapter that provides "${format}".`,
|
|
106
|
+
metadata: { format, registeredFormats: formats },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const modules = new Set(projections.map(projection => projection.module));
|
|
111
|
+
if (modules.size > 1) {
|
|
112
|
+
throw new DotCliError({
|
|
113
|
+
code: DotCliErrorCode.ProjectionConflict,
|
|
114
|
+
message: `Multiple projection modules are registered for format "${format}".`,
|
|
115
|
+
remediation:
|
|
116
|
+
'Only one module may own a projection format. Remove one registration, or make both plugins register the same module specifier.',
|
|
117
|
+
metadata: {
|
|
118
|
+
format,
|
|
119
|
+
projections: projections.map(projection => ({ plugin: projection.plugin, module: projection.module })),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return projections[0]!;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getBunResolver(): { resolveSync(specifier: string, from: string): string } | undefined {
|
|
128
|
+
const candidate = (globalThis as { Bun?: unknown }).Bun;
|
|
129
|
+
if (typeof candidate !== 'object' || candidate === null) return undefined;
|
|
130
|
+
const resolveSync = (candidate as { resolveSync?: unknown }).resolveSync;
|
|
131
|
+
if (typeof resolveSync !== 'function') return undefined;
|
|
132
|
+
return { resolveSync: (specifier, from) => resolveSync.call(candidate, specifier, from) as string };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function importSpecifierFor(resolved: string): string {
|
|
136
|
+
if (resolved.startsWith('file:')) return resolved;
|
|
137
|
+
if (isAbsolute(resolved)) return pathToFileURL(resolved).href;
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveProjectionSpecifier(specifier: string, appFilePath: string): string {
|
|
142
|
+
const fromDir = dirname(appFilePath);
|
|
143
|
+
const bun = getBunResolver();
|
|
144
|
+
if (bun !== undefined) return bun.resolveSync(specifier, fromDir);
|
|
145
|
+
return createRequire(appFilePath).resolve(specifier);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadProjectionModule(
|
|
149
|
+
specifier: string,
|
|
150
|
+
appFilePath: string,
|
|
151
|
+
registeringPlugin: string,
|
|
152
|
+
): Promise<{ module: ProjectionModule; resolved: string }> {
|
|
153
|
+
let resolved: string;
|
|
154
|
+
try {
|
|
155
|
+
resolved = resolveProjectionSpecifier(specifier, appFilePath);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new DotCliError({
|
|
158
|
+
code: DotCliErrorCode.ProjectionImportFailed,
|
|
159
|
+
message: `Failed to resolve projection module "${specifier}".`,
|
|
160
|
+
remediation:
|
|
161
|
+
'Check that the projection module is installed and that package subpath exports include a default condition. Use --module <specifier> to debug with a replacement module.',
|
|
162
|
+
metadata: { specifier, appFilePath, registeringPlugin },
|
|
163
|
+
cause: error,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let imported: unknown;
|
|
168
|
+
try {
|
|
169
|
+
imported = await import(importSpecifierFor(resolved));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new DotCliError({
|
|
172
|
+
code: DotCliErrorCode.ProjectionImportFailed,
|
|
173
|
+
message: `Failed to import projection module "${specifier}".`,
|
|
174
|
+
remediation:
|
|
175
|
+
'Fix the projection module export, or use --module <specifier> to debug with a replacement module.',
|
|
176
|
+
metadata: { specifier, resolved, registeringPlugin },
|
|
177
|
+
cause: error,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof imported !== 'object' || imported === null || typeof (imported as { project?: unknown }).project !== 'function') {
|
|
182
|
+
throw new DotCliError({
|
|
183
|
+
code: DotCliErrorCode.ProjectionImportFailed,
|
|
184
|
+
message: `Projection module "${specifier}" does not export project(manifest).`,
|
|
185
|
+
remediation:
|
|
186
|
+
'Export a named `project(manifest)` function from the projection module. Use --module <specifier> to test a corrected module.',
|
|
187
|
+
metadata: { specifier, resolved, registeringPlugin },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { module: imported as ProjectionModule, resolved };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render an adapter-owned projection. Plain mode prints strings verbatim and
|
|
196
|
+
* JSON values as pretty JSON; JSON mode wraps the document in the standard
|
|
197
|
+
* CLI envelope.
|
|
198
|
+
*/
|
|
199
|
+
export async function renderProjection(
|
|
200
|
+
source: ProjectionSource,
|
|
201
|
+
opts: RenderOptions,
|
|
202
|
+
): Promise<DotCliEnvelope<ProjectionData>> {
|
|
203
|
+
const projection = selectProjection(source.manifest, source.format);
|
|
204
|
+
const specifier = source.module ?? projection.module;
|
|
205
|
+
const { module } = await loadProjectionModule(specifier, source.appFilePath, projection.plugin);
|
|
206
|
+
|
|
207
|
+
let document: JsonValue;
|
|
208
|
+
try {
|
|
209
|
+
document = toProjectionJsonValue(await module.project(source.manifest));
|
|
210
|
+
} catch (error) {
|
|
211
|
+
throw new DotCliError({
|
|
212
|
+
code: DotCliErrorCode.ProjectionExecutionFailed,
|
|
213
|
+
message: `Projection "${source.format}" failed while rendering.`,
|
|
214
|
+
remediation:
|
|
215
|
+
'Fix the projection project(manifest) function so it is pure and returns JSON-round-trip-safe output.',
|
|
216
|
+
metadata: { format: source.format, specifier, registeringPlugin: projection.plugin },
|
|
217
|
+
cause: error,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const envelope: DotCliEnvelope<ProjectionData> = {
|
|
222
|
+
status: 'success',
|
|
223
|
+
command: source.command,
|
|
224
|
+
generatedAt: nowIso(opts),
|
|
225
|
+
data: { format: source.format, document },
|
|
226
|
+
errors: [],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const out = opts.out ?? defaultOut;
|
|
230
|
+
if (opts.json) {
|
|
231
|
+
out(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
232
|
+
} else if (typeof document === 'string') {
|
|
233
|
+
out(`${document}\n`);
|
|
234
|
+
} else {
|
|
235
|
+
out(`${JSON.stringify(document, null, 2)}\n`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return envelope;
|
|
239
|
+
}
|
package/src/define-app.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public entry point for the DOT kernel (v2).
|
|
3
3
|
*
|
|
4
|
-
* `defineApp(name)` returns a `DotAppBuilder` that accumulates
|
|
5
|
-
* `.use(
|
|
4
|
+
* `defineApp(name)` returns a `DotAppBuilder` that accumulates plugins via
|
|
5
|
+
* `.use(plugin)`, then transitions through the 5-hook lifecycle:
|
|
6
6
|
*
|
|
7
7
|
* defineApp -> use* -> configure() -> boot() -> start() -> stop() -> dispose()
|
|
8
8
|
*
|
|
9
|
-
* `.use()` is compile-time guarded: a
|
|
9
|
+
* `.use()` is compile-time guarded: a plugin whose `needs` are not satisfied
|
|
10
10
|
* by services provided so far — or whose provides collide with existing
|
|
11
11
|
* wire keys — fails to typecheck at the call site ("Expected 2 arguments,
|
|
12
12
|
* but got 1", with the diagnostic embedded in the expected second
|
|
@@ -23,7 +23,7 @@ import type { DotDiagnosticsSnapshot } from './diagnostics.js';
|
|
|
23
23
|
import type { DotLifecycleObserver } from './lifecycle-observer.js';
|
|
24
24
|
import type { DotLifecycleState } from './lifecycle.js';
|
|
25
25
|
import type { DotAppManifest } from './manifest.js';
|
|
26
|
-
import type {
|
|
26
|
+
import type { AnyPlugin, EmptyShape, Plugin, ServiceRecord } from './plugin-contract.js';
|
|
27
27
|
import { DotAppImpl } from './kernel/app-instance.js';
|
|
28
28
|
import { renderTimeline } from './timeline.js';
|
|
29
29
|
|
|
@@ -43,26 +43,26 @@ type MismatchedKeys<TAvail, TNeeds> = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
type NeedsError<TAvail extends ServiceRecord, TNeeds extends ServiceRecord> = {
|
|
46
|
-
readonly 'DOT:
|
|
46
|
+
readonly 'DOT: plugin needs services no earlier .use() provides': {
|
|
47
47
|
readonly missing: MissingKeys<TAvail, TNeeds>;
|
|
48
48
|
readonly mismatched: MismatchedKeys<TAvail, TNeeds>;
|
|
49
49
|
};
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
type CollisionError<TAvail, TProvides> = {
|
|
53
|
-
readonly 'DOT:
|
|
53
|
+
readonly 'DOT: plugin provides wire keys already provided (use rename())': keyof TAvail & keyof TProvides;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* A
|
|
57
|
+
* A plugin whose `boot` always throws infers `TProvides = never`, which would
|
|
58
58
|
* poison both the collision check (`keyof never` matches everything) and
|
|
59
|
-
* the accumulated record (`TAvail & never` = `never`). Such a
|
|
59
|
+
* the accumulated record (`TAvail & never` = `never`). Such a plugin provides
|
|
60
60
|
* nothing — normalize to the empty shape.
|
|
61
61
|
*/
|
|
62
|
-
type NormalizeProvides<TP extends ServiceRecord> = [TP] extends [never] ? EmptyShape : TP;
|
|
62
|
+
export type NormalizeProvides<TP extends ServiceRecord> = [TP] extends [never] ? EmptyShape : TP;
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Rest-tuple guard. Satisfied → `[]` (call `.use(
|
|
65
|
+
* Rest-tuple guard. Satisfied → `[]` (call `.use(plugin)` with one argument).
|
|
66
66
|
* Violated → a required second argument of an unconstructible error type,
|
|
67
67
|
* so the call site fails with the diagnostic embedded in the expected type.
|
|
68
68
|
*/
|
|
@@ -76,6 +76,53 @@ export type UseGuard<
|
|
|
76
76
|
: [error: CollisionError<TAvail, TProvides>]
|
|
77
77
|
: [error: NeedsError<TAvail, TNeeds>];
|
|
78
78
|
|
|
79
|
+
type UseAllState<TAvail extends ServiceRecord, TPlugins extends readonly AnyPlugin[]> = TPlugins extends readonly [
|
|
80
|
+
infer Head,
|
|
81
|
+
...infer Tail,
|
|
82
|
+
]
|
|
83
|
+
? Head extends Plugin<infer TNeeds, infer TProvides>
|
|
84
|
+
? UseGuard<TAvail, TNeeds, NormalizeProvides<TProvides>> extends []
|
|
85
|
+
? UseAllState<TAvail & NormalizeProvides<TProvides>, Extract<Tail, readonly AnyPlugin[]>>
|
|
86
|
+
: {
|
|
87
|
+
readonly avail: TAvail;
|
|
88
|
+
readonly error: {
|
|
89
|
+
readonly 'DOT: useAll tuple contains a plugin that cannot be used at this position': UseGuard<
|
|
90
|
+
TAvail,
|
|
91
|
+
TNeeds,
|
|
92
|
+
NormalizeProvides<TProvides>
|
|
93
|
+
>[0];
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
: {
|
|
97
|
+
readonly avail: TAvail;
|
|
98
|
+
readonly error: {
|
|
99
|
+
readonly 'DOT: useAll accepts only plugins': Head;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
: {
|
|
103
|
+
readonly avail: TAvail;
|
|
104
|
+
readonly error: never;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type UseAllAvail<TAvail extends ServiceRecord, TPlugins extends readonly AnyPlugin[]> = UseAllState<
|
|
108
|
+
TAvail,
|
|
109
|
+
TPlugins
|
|
110
|
+
>['avail'];
|
|
111
|
+
|
|
112
|
+
type UseAllTupleGuard<TPlugins extends readonly AnyPlugin[]> = number extends TPlugins['length']
|
|
113
|
+
? [
|
|
114
|
+
error: {
|
|
115
|
+
readonly 'DOT: useAll requires a tuple; add `as const` or pass plugins directly in declaration order': TPlugins;
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
export type UseAllGuard<TAvail extends ServiceRecord, TPlugins extends readonly AnyPlugin[]> = [
|
|
121
|
+
UseAllState<TAvail, TPlugins>['error'],
|
|
122
|
+
] extends [never]
|
|
123
|
+
? UseAllTupleGuard<TPlugins>
|
|
124
|
+
: [error: UseAllState<TAvail, TPlugins>['error']];
|
|
125
|
+
|
|
79
126
|
/* ------------------------------------------------------------------ */
|
|
80
127
|
/* Public app surface */
|
|
81
128
|
/* ------------------------------------------------------------------ */
|
|
@@ -90,7 +137,7 @@ export type DotApp<TServices extends ServiceRecord> = {
|
|
|
90
137
|
/** Current lifecycle state. */
|
|
91
138
|
readonly state: DotLifecycleState;
|
|
92
139
|
/**
|
|
93
|
-
* Services published by booted
|
|
140
|
+
* Services published by booted plugins, keyed by wire key.
|
|
94
141
|
* Empty before `boot()` succeeds.
|
|
95
142
|
*/
|
|
96
143
|
readonly services: TServices;
|
|
@@ -149,19 +196,28 @@ export type DotAppConfigured<TServices extends ServiceRecord> = {
|
|
|
149
196
|
/**
|
|
150
197
|
* Builder produced by `defineApp(name)`.
|
|
151
198
|
*
|
|
152
|
-
* `.use(
|
|
199
|
+
* `.use(plugin)` is type-tracking in both directions: the plugin's `needs` must
|
|
153
200
|
* be satisfied by the services accumulated so far, and its `provides`
|
|
154
201
|
* merge into the accumulated record for the next `.use()`.
|
|
155
202
|
*/
|
|
156
203
|
export type DotAppBuilder<TAvail extends ServiceRecord> = {
|
|
157
204
|
/**
|
|
158
|
-
* Register a
|
|
205
|
+
* Register a plugin. Compile error when the plugin's needs are unsatisfied
|
|
159
206
|
* or its provides collide with existing wire keys.
|
|
160
207
|
*/
|
|
161
208
|
use<TNeeds extends ServiceRecord, TProvides extends ServiceRecord>(
|
|
162
|
-
|
|
209
|
+
plugin: Plugin<TNeeds, TProvides>,
|
|
163
210
|
...guard: UseGuard<TAvail, TNeeds, NormalizeProvides<TProvides>>
|
|
164
211
|
): DotAppBuilder<TAvail & NormalizeProvides<TProvides>>;
|
|
212
|
+
/**
|
|
213
|
+
* Register a tuple of plugins in declaration order. This is type-equivalent
|
|
214
|
+
* to calling `.use()` for each item and keeps the same ordering/collision
|
|
215
|
+
* guard across the whole tuple.
|
|
216
|
+
*/
|
|
217
|
+
useAll<const TPlugins extends readonly AnyPlugin[]>(
|
|
218
|
+
plugins: TPlugins,
|
|
219
|
+
...guard: UseAllGuard<TAvail, TPlugins>
|
|
220
|
+
): DotAppBuilder<UseAllAvail<TAvail, TPlugins>>;
|
|
165
221
|
/** Run the configure phase synchronously. Throws on configure failure. */
|
|
166
222
|
configure(): DotAppConfigured<TAvail>;
|
|
167
223
|
/** Run configure + boot. Throws on configure or boot failure. */
|
|
@@ -173,7 +229,7 @@ export type DotAppBuilder<TAvail extends ServiceRecord> = {
|
|
|
173
229
|
type BuilderState = {
|
|
174
230
|
appName: string;
|
|
175
231
|
appVersion?: string;
|
|
176
|
-
|
|
232
|
+
plugins: AnyPlugin[];
|
|
177
233
|
config?: Readonly<Record<string, unknown>>;
|
|
178
234
|
observers?: readonly DotLifecycleObserver[];
|
|
179
235
|
hookTimeoutMs?: number;
|
|
@@ -184,8 +240,8 @@ type BuilderState = {
|
|
|
184
240
|
*
|
|
185
241
|
* @example
|
|
186
242
|
* const app = await defineApp('my-app')
|
|
187
|
-
* .use(
|
|
188
|
-
* .use(
|
|
243
|
+
* .use(dbPlugin)
|
|
244
|
+
* .use(billingPlugin) // billing's needs must be satisfied by now
|
|
189
245
|
* .boot();
|
|
190
246
|
*
|
|
191
247
|
* await app.start();
|
|
@@ -208,7 +264,7 @@ export function defineApp(
|
|
|
208
264
|
/**
|
|
209
265
|
* Watchdog budget (ms) for each async hook invocation (`boot`, `start`,
|
|
210
266
|
* `stop`, `dispose` — `configure` is sync). A hook exceeding the budget
|
|
211
|
-
* fails with `DOT_LIFECYCLE_E015` naming the
|
|
267
|
+
* fails with `DOT_LIFECYCLE_E015` naming the plugin and hook, and the
|
|
212
268
|
* kernel applies its normal failure rules (boot rollback, teardown
|
|
213
269
|
* aggregation). The hook's promise itself cannot be cancelled — the
|
|
214
270
|
* watchdog makes the hang *visible*, it does not kill it. Default:
|
|
@@ -220,7 +276,7 @@ export function defineApp(
|
|
|
220
276
|
const state: BuilderState = {
|
|
221
277
|
appName: name,
|
|
222
278
|
appVersion: options.version,
|
|
223
|
-
|
|
279
|
+
plugins: [],
|
|
224
280
|
config: options.config,
|
|
225
281
|
observers: options.observers,
|
|
226
282
|
hookTimeoutMs: options.hookTimeoutMs,
|
|
@@ -232,7 +288,7 @@ function buildImpl(state: BuilderState): DotAppImpl {
|
|
|
232
288
|
return new DotAppImpl({
|
|
233
289
|
appName: state.appName,
|
|
234
290
|
appVersion: state.appVersion,
|
|
235
|
-
|
|
291
|
+
plugins: state.plugins,
|
|
236
292
|
config: state.config,
|
|
237
293
|
observers: state.observers,
|
|
238
294
|
hookTimeoutMs: state.hookTimeoutMs,
|
|
@@ -244,10 +300,17 @@ function makeBuilder<TAvail extends ServiceRecord>(state: BuilderState): DotAppB
|
|
|
244
300
|
// at the type level); the single cast below is the same kernel boundary
|
|
245
301
|
// v1 crossed in its wrapApp helper.
|
|
246
302
|
const impl = {
|
|
247
|
-
use(
|
|
303
|
+
use(plugin: AnyPlugin, ..._guard: readonly unknown[]): DotAppBuilder<ServiceRecord> {
|
|
304
|
+
const nextState: BuilderState = {
|
|
305
|
+
...state,
|
|
306
|
+
plugins: [...state.plugins, plugin],
|
|
307
|
+
};
|
|
308
|
+
return makeBuilder<ServiceRecord>(nextState);
|
|
309
|
+
},
|
|
310
|
+
useAll(plugins: readonly AnyPlugin[], ..._guard: readonly unknown[]): DotAppBuilder<ServiceRecord> {
|
|
248
311
|
const nextState: BuilderState = {
|
|
249
312
|
...state,
|
|
250
|
-
|
|
313
|
+
plugins: [...state.plugins, ...plugins],
|
|
251
314
|
};
|
|
252
315
|
return makeBuilder<ServiceRecord>(nextState);
|
|
253
316
|
},
|