@arki/dot 0.1.4 → 0.3.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 +57 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +46 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/render-graph.d.ts +36 -0
- package/dist/cli/render-graph.d.ts.map +1 -0
- package/dist/cli/render-graph.js +70 -0
- package/dist/cli/render-graph.js.map +1 -0
- 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/define-app.d.ts +10 -0
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +2 -0
- package/dist/define-app.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel/app-instance.d.ts +2 -0
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +67 -17
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +2 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/manifest.d.ts +19 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/pip-contract.d.ts +24 -2
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +0 -25
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +1 -1
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js.map +1 -1
- package/dist/signals.d.ts +65 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +97 -0
- package/dist/signals.js.map +1 -0
- package/dist/test-harness.d.ts +75 -1
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +71 -0
- package/dist/test-harness.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +68 -3
- package/src/cli/render-graph.ts +88 -0
- package/src/cli/render-openapi.ts +147 -0
- package/src/define-app.ts +13 -0
- package/src/index.ts +6 -2
- package/src/kernel/app-instance.ts +113 -51
- package/src/lifecycle.ts +2 -0
- package/src/manifest.ts +19 -0
- package/src/pip-contract.ts +31 -3
- package/src/pip.ts +1 -0
- package/src/signals.ts +134 -0
- package/src/test-harness.ts +140 -1
package/src/signals.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal wiring for graceful shutdown.
|
|
3
|
+
*
|
|
4
|
+
* `hookSignals(app)` connects OS termination signals to the app's
|
|
5
|
+
* lifecycle: on the first SIGTERM/SIGINT the app is disposed (which
|
|
6
|
+
* cascades `stop()` when started — transitions are serialized, so a
|
|
7
|
+
* signal racing a slow `start()` still ends in `disposed`), then the
|
|
8
|
+
* signal is re-raised with the default handler restored so the process
|
|
9
|
+
* terminates with conventional signal semantics.
|
|
10
|
+
*
|
|
11
|
+
* Design points:
|
|
12
|
+
*
|
|
13
|
+
* - **First signal drains, second signal kills.** All handlers are
|
|
14
|
+
* removed the moment the first signal fires, so an impatient second
|
|
15
|
+
* Ctrl-C hits the runtime default (immediate termination) instead of
|
|
16
|
+
* a stuck drain.
|
|
17
|
+
* - **Bounded.** `timeoutMs` caps the drain. On timeout or dispose
|
|
18
|
+
* failure the process still terminates — exit code reflects the
|
|
19
|
+
* failure via the re-raised signal; the error is logged on
|
|
20
|
+
* `arki:dot:signals`.
|
|
21
|
+
* - **No `process.exit()`.** The signal is re-raised via
|
|
22
|
+
* `proc.kill(proc.pid, signal)` so the exit status is the standard
|
|
23
|
+
* 128+n signal encoding — supervisors (systemd, Docker, Coolify)
|
|
24
|
+
* read it correctly.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Logger } from '@arki/log';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The slice of `process` the signal hook touches. Injectable so tests can
|
|
31
|
+
* drive signals through a fake without terminating the test runner —
|
|
32
|
+
* defaults to the real `process`.
|
|
33
|
+
*/
|
|
34
|
+
export type SignalTarget = {
|
|
35
|
+
readonly pid: number;
|
|
36
|
+
once(event: string, listener: (...args: never[]) => void): unknown;
|
|
37
|
+
off(event: string, listener: (...args: never[]) => void): unknown;
|
|
38
|
+
kill(pid: number, signal?: string): unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type HookSignalsOptions = {
|
|
42
|
+
/** Signals to intercept. Default: `['SIGTERM', 'SIGINT']`. */
|
|
43
|
+
readonly signals?: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* Maximum time the drain (`dispose()`) may take before the process is
|
|
46
|
+
* terminated anyway. Default: 10 000 ms.
|
|
47
|
+
*/
|
|
48
|
+
readonly timeoutMs?: number;
|
|
49
|
+
/** Test seam — a process-like target. Default: the real `process`. */
|
|
50
|
+
readonly proc?: SignalTarget;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The app surface the hook needs — satisfied by any `DotApp`. */
|
|
54
|
+
export type Disposable = {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
dispose(): Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wire termination signals to `app.dispose()`. Returns an unhook function
|
|
61
|
+
* that removes the handlers without disposing — call it if the app is
|
|
62
|
+
* torn down through another path first.
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* const app = await defineApp('shop').use(...).start();
|
|
66
|
+
* hookSignals(app); // SIGTERM/SIGINT → drain → exit
|
|
67
|
+
* hookSignals(app, { timeoutMs: 30_000 }); // slower drain budget
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function hookSignals(app: Disposable, options: HookSignalsOptions = {}): () => void {
|
|
71
|
+
const signals = options.signals ?? ['SIGTERM', 'SIGINT'];
|
|
72
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
73
|
+
const proc: SignalTarget = options.proc ?? (process as unknown as SignalTarget);
|
|
74
|
+
const logger = new Logger('arki:dot:signals', { 'dot.app.name': app.name });
|
|
75
|
+
|
|
76
|
+
const handlers = new Map<string, () => void>();
|
|
77
|
+
|
|
78
|
+
const unhook = (): void => {
|
|
79
|
+
for (const [signal, handler] of handlers) proc.off(signal, handler);
|
|
80
|
+
handlers.clear();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const signal of signals) {
|
|
84
|
+
const handler = (): void => {
|
|
85
|
+
// First signal wins; the rest fall through to runtime defaults.
|
|
86
|
+
unhook();
|
|
87
|
+
logger.info('signal received — draining', { 'dot.signal': signal, 'dot.drain.timeout.ms': timeoutMs });
|
|
88
|
+
void drainAndReraise({ app, signal, timeoutMs, proc, logger });
|
|
89
|
+
};
|
|
90
|
+
handlers.set(signal, handler);
|
|
91
|
+
proc.once(signal, handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return unhook;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Dispose with a timeout cap, then re-raise the signal for default handling. */
|
|
98
|
+
async function drainAndReraise(args: {
|
|
99
|
+
app: Disposable;
|
|
100
|
+
signal: string;
|
|
101
|
+
timeoutMs: number;
|
|
102
|
+
proc: SignalTarget;
|
|
103
|
+
logger: Logger;
|
|
104
|
+
}): Promise<void> {
|
|
105
|
+
const { app, signal, timeoutMs, proc, logger } = args;
|
|
106
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
const timedOut = new Promise<'timeout'>(resolve => {
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
resolve('timeout');
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
// A pending drain timer must never keep an otherwise-finished process alive.
|
|
112
|
+
if (typeof timer === 'object' && 'unref' in timer) timer.unref();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const outcome = await Promise.race([app.dispose().then(() => 'disposed' as const), timedOut]);
|
|
117
|
+
if (outcome === 'timeout') {
|
|
118
|
+
logger.error('drain timed out — terminating without a clean dispose', {
|
|
119
|
+
'dot.signal': signal,
|
|
120
|
+
'dot.drain.timeout.ms': timeoutMs,
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
logger.info('drain complete', { 'dot.signal': signal });
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error('dispose failed during drain — terminating', {
|
|
127
|
+
'dot.signal': signal,
|
|
128
|
+
'dot.error.message': error instanceof Error ? error.message : String(error),
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
132
|
+
proc.kill(proc.pid, signal);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/test-harness.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { DotApp, DotAppBuilder } from './define-app.js';
|
|
29
|
-
import type { AnyPip, EmptyShape, ServiceRecord } from './pip-contract.js';
|
|
29
|
+
import type { AnyPip, EmptyShape, Pip, ServiceRecord, Token } from './pip-contract.js';
|
|
30
30
|
import { defineApp } from './define-app.js';
|
|
31
31
|
|
|
32
32
|
export type TestAppOptions = {
|
|
@@ -70,3 +70,142 @@ export async function bootTestApp<TServices extends ServiceRecord = EmptyShape>(
|
|
|
70
70
|
): Promise<DotApp<TServices>> {
|
|
71
71
|
return testApp<TServices>(pips, options).boot();
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Rest-tuple guard for {@link TestPipBuilder.boot} — the same trick the app
|
|
76
|
+
* builder's `UseGuard` uses. While any need is still unprovided, `boot()`
|
|
77
|
+
* demands an impossible second argument, so the call site fails with
|
|
78
|
+
* "Expected 2 arguments, but got 1" and the error payload names the
|
|
79
|
+
* missing wire keys and their types.
|
|
80
|
+
*/
|
|
81
|
+
type TestPipBootGuard<TRemaining extends ServiceRecord> = keyof TRemaining extends never
|
|
82
|
+
? readonly []
|
|
83
|
+
: readonly [
|
|
84
|
+
needs: {
|
|
85
|
+
readonly 'DOT-TEST: pip needs still unprovided — call .provide() for each': {
|
|
86
|
+
readonly [K in keyof TRemaining]: TRemaining[K];
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Typed unit-test builder for a single pip — see {@link testPip}.
|
|
93
|
+
*
|
|
94
|
+
* `TRemaining` tracks the wire keys not yet covered by `.provide()`;
|
|
95
|
+
* `TServices` accumulates what the booted app will expose (the fakes plus
|
|
96
|
+
* the pip's own provides).
|
|
97
|
+
*/
|
|
98
|
+
export type TestPipBuilder<TRemaining extends ServiceRecord, TServices extends ServiceRecord> = {
|
|
99
|
+
/**
|
|
100
|
+
* Satisfy one need directly with a fake. Token form — the token names
|
|
101
|
+
* the wire key and carries the type:
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* testPip(reports).provide(Db, fakeDb)
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
provide<K extends keyof TRemaining & string, T extends TRemaining[K]>(
|
|
108
|
+
token: Token<T, K>,
|
|
109
|
+
value: T,
|
|
110
|
+
): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, T>>>;
|
|
111
|
+
/**
|
|
112
|
+
* Satisfy one need directly with a fake. Key form — for anonymous
|
|
113
|
+
* `service<T>()` needs, where the wire key is the property name:
|
|
114
|
+
*
|
|
115
|
+
* ```ts
|
|
116
|
+
* testPip(billing).provide('db', fakeDb)
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
provide<K extends keyof TRemaining & string>(
|
|
120
|
+
key: K,
|
|
121
|
+
value: TRemaining[K],
|
|
122
|
+
): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, TRemaining[K]>>>;
|
|
123
|
+
/** Boot the pip against the provided fakes. Compile error until every need is provided. */
|
|
124
|
+
boot(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
|
|
125
|
+
/** Boot + start. Same guard as {@link TestPipBuilder.boot}. */
|
|
126
|
+
start(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Internal accumulating state for {@link testPip}. */
|
|
130
|
+
type TestPipState = {
|
|
131
|
+
readonly pip: AnyPip;
|
|
132
|
+
readonly fakes: Readonly<Record<string, unknown>>;
|
|
133
|
+
readonly options: TestAppOptions;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Unit-test a single pip with **typed overrides** — the compile-checked
|
|
138
|
+
* counterpart to {@link testApp}'s erased arrays.
|
|
139
|
+
*
|
|
140
|
+
* Each `.provide()` satisfies one of the pip's needs directly (no real
|
|
141
|
+
* provider pip, no dependency chain) and removes it from the builder's
|
|
142
|
+
* remaining-needs type. `boot()` only compiles once every need is covered,
|
|
143
|
+
* and a fake of the wrong shape fails at the `.provide()` call site:
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* import { testPip } from '@arki/dot/test-harness';
|
|
147
|
+
*
|
|
148
|
+
* const app = await testPip(catalog)
|
|
149
|
+
* .provide(Db, fakeDb) // token need
|
|
150
|
+
* .provide('cache', fakeKv) // anonymous need — wire key is the alias
|
|
151
|
+
* .boot();
|
|
152
|
+
*
|
|
153
|
+
* expect(app.services.catalog.list()).toEqual([]);
|
|
154
|
+
* await app.dispose();
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* `service.lazy<T>()` needs accept either a plain `T` fake (the kernel
|
|
158
|
+
* lifts it) or a `Lazy<T>` handle (`lazyOf(value)` is handy here).
|
|
159
|
+
* Lifecycle semantics are the real kernel's — the fakes are published by a
|
|
160
|
+
* synthetic first pip, so reverse-order teardown and lazy auto-dispose
|
|
161
|
+
* behave exactly as in production.
|
|
162
|
+
*/
|
|
163
|
+
export function testPip<TNeeds extends ServiceRecord, TProvides extends ServiceRecord>(
|
|
164
|
+
pip: Pip<TNeeds, TProvides>,
|
|
165
|
+
options: TestAppOptions = {},
|
|
166
|
+
): TestPipBuilder<TNeeds, TProvides> {
|
|
167
|
+
// Erasure seam — same boundary as `makeBuilder` in define-app.ts: the
|
|
168
|
+
// runtime impl is untyped, the type parameters do all the guarding.
|
|
169
|
+
return makeTestPipBuilder({ pip: pip as AnyPip, fakes: {}, options }) as TestPipBuilder<TNeeds, TProvides>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Erased implementation behind {@link testPip}. Mirrors `makeBuilder` in
|
|
174
|
+
* `define-app.ts`: the runtime is untyped, the single cast at the return
|
|
175
|
+
* is the seam, and the type parameters do all the guarding.
|
|
176
|
+
*/
|
|
177
|
+
function makeTestPipBuilder(state: TestPipState): TestPipBuilder<ServiceRecord, ServiceRecord> {
|
|
178
|
+
const bootApp = async (): Promise<DotApp<ServiceRecord>> => {
|
|
179
|
+
const pips: AnyPip[] = [];
|
|
180
|
+
if (Object.keys(state.fakes).length > 0) {
|
|
181
|
+
const fakes = { ...state.fakes };
|
|
182
|
+
pips.push({
|
|
183
|
+
name: `test:fakes(${state.pip.name})`,
|
|
184
|
+
needs: {},
|
|
185
|
+
renames: {},
|
|
186
|
+
hooks: { boot: () => fakes },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
pips.push(state.pip);
|
|
190
|
+
return testApp<ServiceRecord>(pips, {
|
|
191
|
+
name: state.options.name ?? `test:${state.pip.name}`,
|
|
192
|
+
...(state.options.config === undefined ? {} : { config: state.options.config }),
|
|
193
|
+
}).boot();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const impl = {
|
|
197
|
+
provide(tokenOrKey: Token<unknown, string> | string, value: unknown) {
|
|
198
|
+
const key = typeof tokenOrKey === 'string' ? tokenOrKey : tokenOrKey.key;
|
|
199
|
+
return makeTestPipBuilder({ ...state, fakes: { ...state.fakes, [key]: value } });
|
|
200
|
+
},
|
|
201
|
+
async boot(..._guard: readonly unknown[]) {
|
|
202
|
+
return bootApp();
|
|
203
|
+
},
|
|
204
|
+
async start(..._guard: readonly unknown[]) {
|
|
205
|
+
const app = await bootApp();
|
|
206
|
+
await app.start();
|
|
207
|
+
return app;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return impl as TestPipBuilder<ServiceRecord, ServiceRecord>;
|
|
211
|
+
}
|