@arki/dot 0.3.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 +9 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +54 -23
- 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-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 -48
- 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 +29 -21
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +1 -0
- 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 +76 -29
- 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 +37 -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 -50
- package/src/{pip-contract.ts → plugin-contract.ts} +73 -62
- package/src/{pip.ts → plugin.ts} +37 -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
- package/src/cli/render-openapi.ts +0 -147
package/src/test-harness.ts
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Test harness for unit-testing DOT
|
|
2
|
+
* Test harness for unit-testing DOT plugins.
|
|
3
3
|
*
|
|
4
|
-
* Provides `testApp` — a convenience wrapper that lets
|
|
4
|
+
* Provides `testApp` — a convenience wrapper that lets plugin authors verify
|
|
5
5
|
* lifecycle behaviour, registration, and service publishing without dragging
|
|
6
6
|
* in concrete framework dependencies.
|
|
7
7
|
*
|
|
8
|
-
* NOTE: `testApp` takes an erased
|
|
8
|
+
* NOTE: `testApp` takes an erased plugin array, so the compile-time wiring
|
|
9
9
|
* guard does not apply here — the kernel's runtime validation (unsatisfied
|
|
10
10
|
* needs, collisions) still does. Use `defineApp(...).use(...)` chains in
|
|
11
11
|
* tests that should exercise the type-level guard.
|
|
12
12
|
*
|
|
13
13
|
* @example
|
|
14
|
-
* import { testApp,
|
|
14
|
+
* import { testApp, plugin } from '@arki/dot';
|
|
15
15
|
*
|
|
16
|
-
* const
|
|
16
|
+
* const myPlugin = plugin({
|
|
17
17
|
* name: 'counter',
|
|
18
18
|
* boot: () => ({ counter: { value: 0 } }),
|
|
19
19
|
* });
|
|
20
20
|
*
|
|
21
21
|
* it('publishes a counter service', async () => {
|
|
22
|
-
* const app = await bootTestApp<{ counter: { value: number } }>([
|
|
22
|
+
* const app = await bootTestApp<{ counter: { value: number } }>([myPlugin]);
|
|
23
23
|
* expect(app.services.counter.value).toBe(0);
|
|
24
24
|
* await app.dispose();
|
|
25
25
|
* });
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { DotApp, DotAppBuilder } from './define-app.js';
|
|
29
|
-
import type {
|
|
29
|
+
import type { AnyPlugin, EmptyShape, Plugin, ServiceRecord, Token } from './plugin-contract.js';
|
|
30
30
|
import { defineApp } from './define-app.js';
|
|
31
31
|
|
|
32
32
|
export type TestAppOptions = {
|
|
@@ -37,24 +37,24 @@ export type TestAppOptions = {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
|
-
* Guard-free view of the builder for erased
|
|
40
|
+
* Guard-free view of the builder for erased plugin arrays. The runtime `use`
|
|
41
41
|
* implementation accepts the missing guard argument (it is type-level
|
|
42
42
|
* only), so this seam is safe — the kernel still validates wiring at boot.
|
|
43
43
|
*/
|
|
44
44
|
type LooseBuilder = {
|
|
45
|
-
use(
|
|
45
|
+
use(plugin: AnyPlugin): LooseBuilder;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Build a DOT app builder pre-populated with the given
|
|
49
|
+
* Build a DOT app builder pre-populated with the given plugins, ready to
|
|
50
50
|
* `.configure()`, `.boot()` or `.start()` from a test.
|
|
51
51
|
*/
|
|
52
52
|
export function testApp<TServices extends ServiceRecord = EmptyShape>(
|
|
53
|
-
|
|
53
|
+
plugins: readonly AnyPlugin[] = [],
|
|
54
54
|
options: TestAppOptions = {},
|
|
55
55
|
): DotAppBuilder<TServices> {
|
|
56
56
|
let builder: unknown = defineApp(options.name ?? 'test-app', { config: options.config });
|
|
57
|
-
for (const p of
|
|
57
|
+
for (const p of plugins) {
|
|
58
58
|
builder = (builder as LooseBuilder).use(p);
|
|
59
59
|
}
|
|
60
60
|
return builder as DotAppBuilder<TServices>;
|
|
@@ -65,87 +65,87 @@ export function testApp<TServices extends ServiceRecord = EmptyShape>(
|
|
|
65
65
|
* calling `app.dispose()` when finished.
|
|
66
66
|
*/
|
|
67
67
|
export async function bootTestApp<TServices extends ServiceRecord = EmptyShape>(
|
|
68
|
-
|
|
68
|
+
plugins: readonly AnyPlugin[] = [],
|
|
69
69
|
options: TestAppOptions = {},
|
|
70
70
|
): Promise<DotApp<TServices>> {
|
|
71
|
-
return testApp<TServices>(
|
|
71
|
+
return testApp<TServices>(plugins, options).boot();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* Rest-tuple guard for {@link
|
|
75
|
+
* Rest-tuple guard for {@link TestPluginBuilder.boot} — the same trick the app
|
|
76
76
|
* builder's `UseGuard` uses. While any need is still unprovided, `boot()`
|
|
77
77
|
* demands an impossible second argument, so the call site fails with
|
|
78
78
|
* "Expected 2 arguments, but got 1" and the error payload names the
|
|
79
79
|
* missing wire keys and their types.
|
|
80
80
|
*/
|
|
81
|
-
type
|
|
81
|
+
type TestPluginBootGuard<TRemaining extends ServiceRecord> = keyof TRemaining extends never
|
|
82
82
|
? readonly []
|
|
83
83
|
: readonly [
|
|
84
84
|
needs: {
|
|
85
|
-
readonly 'DOT-TEST:
|
|
85
|
+
readonly 'DOT-TEST: plugin needs still unprovided — call .provide() for each': {
|
|
86
86
|
readonly [K in keyof TRemaining]: TRemaining[K];
|
|
87
87
|
};
|
|
88
88
|
},
|
|
89
89
|
];
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Typed unit-test builder for a single
|
|
92
|
+
* Typed unit-test builder for a single plugin — see {@link testPlugin}.
|
|
93
93
|
*
|
|
94
94
|
* `TRemaining` tracks the wire keys not yet covered by `.provide()`;
|
|
95
95
|
* `TServices` accumulates what the booted app will expose (the fakes plus
|
|
96
|
-
* the
|
|
96
|
+
* the plugin's own provides).
|
|
97
97
|
*/
|
|
98
|
-
export type
|
|
98
|
+
export type TestPluginBuilder<TRemaining extends ServiceRecord, TServices extends ServiceRecord> = {
|
|
99
99
|
/**
|
|
100
100
|
* Satisfy one need directly with a fake. Token form — the token names
|
|
101
101
|
* the wire key and carries the type:
|
|
102
102
|
*
|
|
103
103
|
* ```ts
|
|
104
|
-
*
|
|
104
|
+
* testPlugin(reports).provide(Db, fakeDb)
|
|
105
105
|
* ```
|
|
106
106
|
*/
|
|
107
107
|
provide<K extends keyof TRemaining & string, T extends TRemaining[K]>(
|
|
108
108
|
token: Token<T, K>,
|
|
109
109
|
value: T,
|
|
110
|
-
):
|
|
110
|
+
): TestPluginBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, T>>>;
|
|
111
111
|
/**
|
|
112
112
|
* Satisfy one need directly with a fake. Key form — for anonymous
|
|
113
113
|
* `service<T>()` needs, where the wire key is the property name:
|
|
114
114
|
*
|
|
115
115
|
* ```ts
|
|
116
|
-
*
|
|
116
|
+
* testPlugin(billing).provide('db', fakeDb)
|
|
117
117
|
* ```
|
|
118
118
|
*/
|
|
119
119
|
provide<K extends keyof TRemaining & string>(
|
|
120
120
|
key: K,
|
|
121
121
|
value: TRemaining[K],
|
|
122
|
-
):
|
|
123
|
-
/** Boot the
|
|
124
|
-
boot(...guard:
|
|
125
|
-
/** Boot + start. Same guard as {@link
|
|
126
|
-
start(...guard:
|
|
122
|
+
): TestPluginBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, TRemaining[K]>>>;
|
|
123
|
+
/** Boot the plugin against the provided fakes. Compile error until every need is provided. */
|
|
124
|
+
boot(...guard: TestPluginBootGuard<TRemaining>): Promise<DotApp<TServices>>;
|
|
125
|
+
/** Boot + start. Same guard as {@link TestPluginBuilder.boot}. */
|
|
126
|
+
start(...guard: TestPluginBootGuard<TRemaining>): Promise<DotApp<TServices>>;
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
-
/** Internal accumulating state for {@link
|
|
130
|
-
type
|
|
131
|
-
readonly
|
|
129
|
+
/** Internal accumulating state for {@link testPlugin}. */
|
|
130
|
+
type TestPluginState = {
|
|
131
|
+
readonly plugin: AnyPlugin;
|
|
132
132
|
readonly fakes: Readonly<Record<string, unknown>>;
|
|
133
133
|
readonly options: TestAppOptions;
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Unit-test a single
|
|
137
|
+
* Unit-test a single plugin with **typed overrides** — the compile-checked
|
|
138
138
|
* counterpart to {@link testApp}'s erased arrays.
|
|
139
139
|
*
|
|
140
|
-
* Each `.provide()` satisfies one of the
|
|
141
|
-
* provider
|
|
140
|
+
* Each `.provide()` satisfies one of the plugin's needs directly (no real
|
|
141
|
+
* provider plugin, no dependency chain) and removes it from the builder's
|
|
142
142
|
* remaining-needs type. `boot()` only compiles once every need is covered,
|
|
143
143
|
* and a fake of the wrong shape fails at the `.provide()` call site:
|
|
144
144
|
*
|
|
145
145
|
* ```ts
|
|
146
|
-
* import {
|
|
146
|
+
* import { testPlugin } from '@arki/dot/test-harness';
|
|
147
147
|
*
|
|
148
|
-
* const app = await
|
|
148
|
+
* const app = await testPlugin(catalog)
|
|
149
149
|
* .provide(Db, fakeDb) // token need
|
|
150
150
|
* .provide('cache', fakeKv) // anonymous need — wire key is the alias
|
|
151
151
|
* .boot();
|
|
@@ -157,38 +157,39 @@ type TestPipState = {
|
|
|
157
157
|
* `service.lazy<T>()` needs accept either a plain `T` fake (the kernel
|
|
158
158
|
* lifts it) or a `Lazy<T>` handle (`lazyOf(value)` is handy here).
|
|
159
159
|
* Lifecycle semantics are the real kernel's — the fakes are published by a
|
|
160
|
-
* synthetic first
|
|
160
|
+
* synthetic first plugin, so reverse-order teardown and lazy auto-dispose
|
|
161
161
|
* behave exactly as in production.
|
|
162
162
|
*/
|
|
163
|
-
export function
|
|
164
|
-
|
|
163
|
+
export function testPlugin<TNeeds extends ServiceRecord, TProvides extends ServiceRecord>(
|
|
164
|
+
plugin: Plugin<TNeeds, TProvides>,
|
|
165
165
|
options: TestAppOptions = {},
|
|
166
|
-
):
|
|
166
|
+
): TestPluginBuilder<TNeeds, TProvides> {
|
|
167
167
|
// Erasure seam — same boundary as `makeBuilder` in define-app.ts: the
|
|
168
168
|
// runtime impl is untyped, the type parameters do all the guarding.
|
|
169
|
-
return
|
|
169
|
+
return makeTestPluginBuilder({ plugin: plugin as AnyPlugin, fakes: {}, options }) as TestPluginBuilder<TNeeds, TProvides>;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
* Erased implementation behind {@link
|
|
173
|
+
* Erased implementation behind {@link testPlugin}. Mirrors `makeBuilder` in
|
|
174
174
|
* `define-app.ts`: the runtime is untyped, the single cast at the return
|
|
175
175
|
* is the seam, and the type parameters do all the guarding.
|
|
176
176
|
*/
|
|
177
|
-
function
|
|
177
|
+
function makeTestPluginBuilder(state: TestPluginState): TestPluginBuilder<ServiceRecord, ServiceRecord> {
|
|
178
178
|
const bootApp = async (): Promise<DotApp<ServiceRecord>> => {
|
|
179
|
-
const
|
|
179
|
+
const plugins: AnyPlugin[] = [];
|
|
180
180
|
if (Object.keys(state.fakes).length > 0) {
|
|
181
181
|
const fakes = { ...state.fakes };
|
|
182
|
-
|
|
183
|
-
name: `test:fakes(${state.
|
|
182
|
+
plugins.push({
|
|
183
|
+
name: `test:fakes(${state.plugin.name})`,
|
|
184
184
|
needs: {},
|
|
185
|
+
actions: [],
|
|
185
186
|
renames: {},
|
|
186
187
|
hooks: { boot: () => fakes },
|
|
187
188
|
});
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
-
return testApp<ServiceRecord>(
|
|
191
|
-
name: state.options.name ?? `test:${state.
|
|
190
|
+
plugins.push(state.plugin);
|
|
191
|
+
return testApp<ServiceRecord>(plugins, {
|
|
192
|
+
name: state.options.name ?? `test:${state.plugin.name}`,
|
|
192
193
|
...(state.options.config === undefined ? {} : { config: state.options.config }),
|
|
193
194
|
}).boot();
|
|
194
195
|
};
|
|
@@ -196,7 +197,7 @@ function makeTestPipBuilder(state: TestPipState): TestPipBuilder<ServiceRecord,
|
|
|
196
197
|
const impl = {
|
|
197
198
|
provide(tokenOrKey: Token<unknown, string> | string, value: unknown) {
|
|
198
199
|
const key = typeof tokenOrKey === 'string' ? tokenOrKey : tokenOrKey.key;
|
|
199
|
-
return
|
|
200
|
+
return makeTestPluginBuilder({ ...state, fakes: { ...state.fakes, [key]: value } });
|
|
200
201
|
},
|
|
201
202
|
async boot(..._guard: readonly unknown[]) {
|
|
202
203
|
return bootApp();
|
|
@@ -207,5 +208,5 @@ function makeTestPipBuilder(state: TestPipState): TestPipBuilder<ServiceRecord,
|
|
|
207
208
|
return app;
|
|
208
209
|
},
|
|
209
210
|
};
|
|
210
|
-
return impl as
|
|
211
|
+
return impl as TestPluginBuilder<ServiceRecord, ServiceRecord>;
|
|
211
212
|
}
|
package/src/timeline.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ASCII waterfall renderer for DOT lifecycle diagnostics.
|
|
3
3
|
*
|
|
4
|
-
* Given a `DotDiagnosticsSnapshot`, builds a compact per-phase, per-
|
|
4
|
+
* Given a `DotDiagnosticsSnapshot`, builds a compact per-phase, per-plugin
|
|
5
5
|
* duration chart suitable for printing in a terminal or embedding in a
|
|
6
6
|
* `dot doctor` text report. No colour, no Unicode beyond `█` so the
|
|
7
7
|
* output stays clean in log aggregators and CI.
|
|
@@ -38,7 +38,7 @@ export type RenderTimelineOptions = {
|
|
|
38
38
|
/** Maximum bar width in characters (default `50`). */
|
|
39
39
|
readonly barWidth?: number;
|
|
40
40
|
/**
|
|
41
|
-
* When `true`, also emit any
|
|
41
|
+
* When `true`, also emit any plugin's diagnostic `issues[]` underneath
|
|
42
42
|
* the bar so failure reasons are visible in the timeline view.
|
|
43
43
|
* Default `true`.
|
|
44
44
|
*/
|
|
@@ -78,8 +78,8 @@ export function renderTimeline(snapshot: DotDiagnosticsSnapshot, opts: RenderTim
|
|
|
78
78
|
lines.push(title);
|
|
79
79
|
lines.push('─'.repeat(title.length));
|
|
80
80
|
|
|
81
|
-
// Layout: 2-space indent, 10-char
|
|
82
|
-
const
|
|
81
|
+
// Layout: 2-space indent, 10-char plugin column, 8-char duration column, then bar.
|
|
82
|
+
const PLUGIN_COL = 12;
|
|
83
83
|
const DUR_COL = 8;
|
|
84
84
|
const indent = ' ';
|
|
85
85
|
|
|
@@ -94,9 +94,9 @@ export function renderTimeline(snapshot: DotDiagnosticsSnapshot, opts: RenderTim
|
|
|
94
94
|
const durationMs = entry.durationMs ?? 0;
|
|
95
95
|
const bar = makeBar(durationMs, maxDuration, barWidth);
|
|
96
96
|
const failedMark = entry.state === 'failed' ? ' ✗' : '';
|
|
97
|
-
const
|
|
97
|
+
const pluginCol = entry.plugin.padEnd(PLUGIN_COL);
|
|
98
98
|
const durCol = formatDuration(durationMs).padStart(DUR_COL);
|
|
99
|
-
lines.push(`${indent}${
|
|
99
|
+
lines.push(`${indent}${pluginCol}${durCol} ${bar}${failedMark}`);
|
|
100
100
|
if (showIssues && entry.issues.length > 0) {
|
|
101
101
|
for (const issue of entry.issues) {
|
|
102
102
|
lines.push(`${indent} └─ ${issue.code}: ${issue.message}`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What this app is
|
|
4
4
|
|
|
5
|
-
A DOT app composed via `defineApp(...).use(
|
|
5
|
+
A DOT app composed via `defineApp(...).use(plugin).boot()`. The runtime
|
|
6
6
|
graph is statically inspectable via `app.manifest` and runtime-inspectable
|
|
7
7
|
via `app.diagnostics`.
|
|
8
8
|
|
|
@@ -26,7 +26,7 @@ command exits zero.
|
|
|
26
26
|
- Env schema lives at `src/env.ts` — keep it narrow; one entry per env
|
|
27
27
|
key the app actually reads.
|
|
28
28
|
- Tests live under `tests/` and import the app via the `src/` barrel.
|
|
29
|
-
- Add new
|
|
29
|
+
- Add new plugins by wiring `.use(plugin(options))` between
|
|
30
30
|
`defineApp('{{name}}')` and `.boot()` in `src/app.ts`.
|
|
31
31
|
|
|
32
32
|
## Public / private boundaries
|
|
@@ -36,8 +36,8 @@ command exits zero.
|
|
|
36
36
|
app reads belong here.
|
|
37
37
|
- Don't reach into `@arki/dot` internals (anything under
|
|
38
38
|
`@arki/dot/kernel`, `@arki/dot/legacy`, or `dist/` subpaths). Use the
|
|
39
|
-
public `defineApp` / `
|
|
40
|
-
`
|
|
39
|
+
public `defineApp` / `plugin` API from `@arki/dot` and the
|
|
40
|
+
`plugin` / `service` / `rename` symbols from `@arki/dot/plugin`.
|
|
41
41
|
|
|
42
42
|
## DOT artifact locations
|
|
43
43
|
|
|
@@ -47,14 +47,14 @@ command exits zero.
|
|
|
47
47
|
`started` -> `stopped` -> `disposed`).
|
|
48
48
|
- Errors carry stable `code`, `severity`, `remediation`, `docsUrl`.
|
|
49
49
|
|
|
50
|
-
## Adding a
|
|
50
|
+
## Adding a plugin
|
|
51
51
|
|
|
52
52
|
1. Install the adapter package: `{{pkgManager}} add @arki/<pkg>`.
|
|
53
|
-
2. Import its DOT adapter: `import { <
|
|
53
|
+
2. Import its DOT adapter: `import { <plugin> } from '@arki/<pkg>/dot';`.
|
|
54
54
|
3. Chain it into the builder before `.boot()`:
|
|
55
|
-
`.use(<
|
|
56
|
-
4. Verify with `bunx dot explain --json` — the new
|
|
57
|
-
under `.data.
|
|
55
|
+
`.use(<plugin>(options))`.
|
|
56
|
+
4. Verify with `bunx dot explain --json` — the new plugin should appear
|
|
57
|
+
under `.data.plugins`.
|
|
58
58
|
|
|
59
59
|
## Diagnostics triage
|
|
60
60
|
|
|
@@ -17,7 +17,7 @@ bunx dot explain --json
|
|
|
17
17
|
bunx dot doctor --json
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
`dot explain` prints the static graph (
|
|
20
|
+
`dot explain` prints the static graph (plugins, services, routes, dependencies,
|
|
21
21
|
lifecycle hooks). `dot doctor` boots the app and prints the runtime
|
|
22
22
|
diagnostics envelope (the same five arrays, plus issues).
|
|
23
23
|
|
|
@@ -17,7 +17,7 @@ test('app boots and exposes env service', () => {
|
|
|
17
17
|
|
|
18
18
|
test('diagnostics has all 5 arrays', () => {
|
|
19
19
|
const { diagnostics } = booted;
|
|
20
|
-
expect(diagnostics.
|
|
20
|
+
expect(diagnostics.plugins).toBeDefined();
|
|
21
21
|
expect(diagnostics.routes).toBeDefined();
|
|
22
22
|
expect(diagnostics.services).toBeDefined();
|
|
23
23
|
expect(diagnostics.lifecycle).toBeDefined();
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Renderer for `dot explain --openapi`.
|
|
3
|
-
*
|
|
4
|
-
* Emits an OpenAPI 3.1 document straight from `manifest.routes` — no boot
|
|
5
|
-
* required. Adapters (e.g. `@arki/http`'s `registerRoutes`) convert their
|
|
6
|
-
* validation schemas to JSON Schema at `configure` time and register them
|
|
7
|
-
* via `ctx.registerRoute`, so the manifest already carries everything the
|
|
8
|
-
* document needs: methods, paths, per-location input schemas, output
|
|
9
|
-
* schemas, and the `streaming` flag for `text/event-stream` responses.
|
|
10
|
-
*
|
|
11
|
-
* Deterministic: paths and operations follow manifest registration order
|
|
12
|
-
* (which follows pip declaration order). Only `transport: 'http'` routes
|
|
13
|
-
* are rendered — rpc mounts have their own schema story.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { DotAppManifest, RouteManifest } from '../manifest.js';
|
|
17
|
-
import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
|
|
18
|
-
|
|
19
|
-
/** Envelope payload for OpenAPI output. */
|
|
20
|
-
export type OpenApiData = {
|
|
21
|
-
format: 'openapi';
|
|
22
|
-
/** OpenAPI 3.1 document. */
|
|
23
|
-
document: Record<string, unknown>;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function openApiPath(path: string): string {
|
|
27
|
-
return path.replaceAll(/:(\w+)/g, '{$1}');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function pathParamNames(path: string): readonly string[] {
|
|
31
|
-
return [...path.matchAll(/:(\w+)/g)]
|
|
32
|
-
.map(match => match[1])
|
|
33
|
-
.filter((name): name is string => name !== undefined);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function buildOperation(route: RouteManifest): Record<string, unknown> {
|
|
37
|
-
const parameters: Record<string, unknown>[] = pathParamNames(route.path ?? '').map(name => ({
|
|
38
|
-
name,
|
|
39
|
-
in: 'path',
|
|
40
|
-
required: true,
|
|
41
|
-
schema: { type: 'string' },
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
const querySchema = route.input?.query;
|
|
45
|
-
if (querySchema !== undefined) {
|
|
46
|
-
const properties = (querySchema['properties'] ?? {}) as Readonly<Record<string, unknown>>;
|
|
47
|
-
const required = new Set(querySchema['required'] as readonly string[] | undefined);
|
|
48
|
-
for (const [name, propertySchema] of Object.entries(properties)) {
|
|
49
|
-
parameters.push({ name, in: 'query', required: required.has(name), schema: propertySchema });
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const operation: Record<string, unknown> = {
|
|
54
|
-
operationId: route.id,
|
|
55
|
-
tags: [route.pip],
|
|
56
|
-
};
|
|
57
|
-
if (route.description !== undefined) operation['summary'] = route.description;
|
|
58
|
-
if (parameters.length > 0) operation['parameters'] = parameters;
|
|
59
|
-
|
|
60
|
-
const bodySchema = route.input?.body;
|
|
61
|
-
if (bodySchema !== undefined) {
|
|
62
|
-
operation['requestBody'] = {
|
|
63
|
-
required: true,
|
|
64
|
-
content: { 'application/json': { schema: bodySchema } },
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (route.streaming === true) {
|
|
69
|
-
operation['responses'] = {
|
|
70
|
-
'200': {
|
|
71
|
-
description: 'event stream',
|
|
72
|
-
content: {
|
|
73
|
-
'text/event-stream': route.output === undefined ? {} : { schema: route.output },
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
} else if (route.output === undefined) {
|
|
78
|
-
operation['responses'] = { '200': { description: 'success' } };
|
|
79
|
-
} else {
|
|
80
|
-
operation['responses'] = {
|
|
81
|
-
'200': {
|
|
82
|
-
description: 'success',
|
|
83
|
-
content: { 'application/json': { schema: route.output } },
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return operation;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Build an OpenAPI 3.1 document from a manifest. First registration wins
|
|
93
|
-
* on a method+path collision — collisions are an app bug the serving
|
|
94
|
-
* adapter rejects at boot; the document renderer stays total.
|
|
95
|
-
*/
|
|
96
|
-
export function buildOpenApiDocument(manifest: DotAppManifest): Record<string, unknown> {
|
|
97
|
-
const paths: Record<string, Record<string, unknown>> = {};
|
|
98
|
-
for (const route of manifest.routes) {
|
|
99
|
-
if (route.transport !== 'http') continue;
|
|
100
|
-
if (route.path === undefined) continue;
|
|
101
|
-
const path = openApiPath(route.path);
|
|
102
|
-
const method = (route.method ?? 'GET').toLowerCase();
|
|
103
|
-
const entry = (paths[path] ??= {});
|
|
104
|
-
if (entry[method] !== undefined) continue;
|
|
105
|
-
entry[method] = buildOperation(route);
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
openapi: '3.1.0',
|
|
109
|
-
info: {
|
|
110
|
-
title: manifest.app.name,
|
|
111
|
-
version: manifest.app.version ?? '0.0.0',
|
|
112
|
-
},
|
|
113
|
-
paths,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Render the OpenAPI output. Plain mode prints the document as JSON
|
|
119
|
-
* (pipe it into a file or a swagger viewer); `--json` wraps it in the
|
|
120
|
-
* standard CLI envelope under `data.document`.
|
|
121
|
-
*/
|
|
122
|
-
export function renderOpenApi(
|
|
123
|
-
source: { manifest: DotAppManifest; command: 'explain' },
|
|
124
|
-
opts: RenderOptions,
|
|
125
|
-
): DotCliEnvelope<OpenApiData> {
|
|
126
|
-
const document = buildOpenApiDocument(source.manifest);
|
|
127
|
-
const nowFactory = opts.now ?? (() => new Date());
|
|
128
|
-
const envelope: DotCliEnvelope<OpenApiData> = {
|
|
129
|
-
status: 'success',
|
|
130
|
-
command: source.command,
|
|
131
|
-
generatedAt: nowFactory().toISOString(),
|
|
132
|
-
data: { format: 'openapi', document },
|
|
133
|
-
errors: [],
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const out =
|
|
137
|
-
opts.out ??
|
|
138
|
-
((line: string) => {
|
|
139
|
-
process.stdout.write(line);
|
|
140
|
-
});
|
|
141
|
-
if (opts.json) {
|
|
142
|
-
out(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
143
|
-
} else {
|
|
144
|
-
out(`${JSON.stringify(document, null, 2)}\n`);
|
|
145
|
-
}
|
|
146
|
-
return envelope;
|
|
147
|
-
}
|