@arki/dot 0.1.3 → 0.2.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 +427 -18
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -1
- 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/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 +4 -2
- 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/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 +36 -2
- package/src/cli/render-graph.ts +88 -0
- package/src/define-app.ts +13 -0
- package/src/index.ts +5 -2
- package/src/kernel/app-instance.ts +113 -51
- package/src/lifecycle.ts +2 -0
- package/src/signals.ts +134 -0
- package/src/test-harness.ts +140 -1
package/README.md
CHANGED
|
@@ -3,26 +3,31 @@
|
|
|
3
3
|
> TypeScript-first application composition framework for the ARKI package family.
|
|
4
4
|
|
|
5
5
|
`@arki/dot` is the kernel that wires **pips**, lifecycle hooks, dependency
|
|
6
|
-
|
|
7
|
-
authors a stable contract for declaring how their package
|
|
8
|
-
app, and gives app developers a single place to wire
|
|
6
|
+
injection, and diagnostics into a deterministic application boot. It gives
|
|
7
|
+
library authors a stable contract for declaring how their package
|
|
8
|
+
participates in an app, and gives app developers a single place to wire
|
|
9
|
+
those packages together — with the type checker verifying the wiring before
|
|
10
|
+
anything runs.
|
|
9
11
|
|
|
10
12
|
## What is a pip?
|
|
11
13
|
|
|
12
14
|
A **pip** is the unit a DOT app is built from — one self-describing,
|
|
13
15
|
lifecycle-aware piece of an application. Each pip:
|
|
14
16
|
|
|
15
|
-
- declares a **name**, **version**, and the
|
|
17
|
+
- declares a **name**, **version**, and the services it **needs** as a shape
|
|
18
|
+
of type witnesses;
|
|
19
|
+
- **provides** services by returning them from its `boot` hook — provides
|
|
20
|
+
are *inferred from the return type*, never declared separately;
|
|
16
21
|
- runs a **5-hook lifecycle** — `configure` → `boot` → `start` → `stop` → `dispose`;
|
|
17
|
-
- **publishes typed services** to a shared, type-safe registry that later
|
|
18
|
-
can read from;
|
|
19
|
-
- **composes deterministically** — pips boot in declaration order and
|
|
20
|
-
in reverse.
|
|
22
|
+
- **publishes typed services** to a shared, type-safe registry that later
|
|
23
|
+
pips can read from;
|
|
24
|
+
- **composes deterministically** — pips boot in declaration order and
|
|
25
|
+
dispose in reverse.
|
|
21
26
|
|
|
22
27
|
The name comes from the small dots on dice, dominoes, and music notation:
|
|
23
|
-
each pip is one small mark, and the *combination* of pips is what gives the
|
|
24
|
-
its value. Two pips on a die make a value of two; six pips make six. The
|
|
25
|
-
**are** the app — not optional add-ons to a hidden core.
|
|
28
|
+
each pip is one small mark, and the *combination* of pips is what gives the
|
|
29
|
+
app its value. Two pips on a die make a value of two; six pips make six. The
|
|
30
|
+
pips **are** the app — not optional add-ons to a hidden core.
|
|
26
31
|
|
|
27
32
|
```ts
|
|
28
33
|
import { defineApp } from '@arki/dot';
|
|
@@ -61,6 +66,19 @@ bun add @arki/dot
|
|
|
61
66
|
```ts
|
|
62
67
|
import { defineApp, pip, service } from '@arki/dot';
|
|
63
68
|
|
|
69
|
+
type Db = { query(sql: string): Promise<unknown[]> };
|
|
70
|
+
|
|
71
|
+
const dbPip = pip({
|
|
72
|
+
name: 'database',
|
|
73
|
+
async boot() {
|
|
74
|
+
const db = await openDb(process.env.DATABASE_URL);
|
|
75
|
+
return { db }; // ← this IS the provides declaration
|
|
76
|
+
},
|
|
77
|
+
async dispose({ db }) {
|
|
78
|
+
await db.close();
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
64
82
|
const billingPip = pip({
|
|
65
83
|
name: 'billing',
|
|
66
84
|
version: '1.0.0',
|
|
@@ -94,6 +112,371 @@ await app.stop();
|
|
|
94
112
|
await app.dispose();
|
|
95
113
|
```
|
|
96
114
|
|
|
115
|
+
There is no registry to configure, no decorators, no reflection. The `needs`
|
|
116
|
+
shape is the injection contract; the `boot` return type is the provision
|
|
117
|
+
contract; the builder's type-level guard connects the two.
|
|
118
|
+
|
|
119
|
+
## Dependency injection
|
|
120
|
+
|
|
121
|
+
### Needs are witnesses, provides are inferred
|
|
122
|
+
|
|
123
|
+
`service<T>()` creates a phantom **type witness** — a runtime no-op that
|
|
124
|
+
carries `T` at the type level. The property name you give it is your local
|
|
125
|
+
alias *and* (for anonymous witnesses) the wire key:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const search = pip({
|
|
129
|
+
name: 'search',
|
|
130
|
+
needs: {
|
|
131
|
+
db: service<Db>(), // wire key 'db', local alias 'db'
|
|
132
|
+
log: service<Logger>(), // wire key 'log'
|
|
133
|
+
},
|
|
134
|
+
async boot({ db, log, $app }) { // $app/$pip/$config: kernel context
|
|
135
|
+
log.info(`indexing for ${$app}`);
|
|
136
|
+
return { search: await buildIndex(db) };
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`start`, `stop`, and `dispose` additionally receive the pip's **own
|
|
142
|
+
provides** — and because teardown runs in reverse declaration order, a pip's
|
|
143
|
+
needs are still alive in its `dispose`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
async dispose({ search, db }) { // own provide + still-live need
|
|
147
|
+
await search.flush(db);
|
|
148
|
+
},
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Wrong wiring does not compile
|
|
152
|
+
|
|
153
|
+
Declaration order IS boot order. The `.use()` guard makes unsatisfied needs,
|
|
154
|
+
type mismatches, and key collisions **compile errors at the call site**:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
defineApp('shop')
|
|
158
|
+
.use(billing) // ❌ "Expected 2 arguments, but got 1."
|
|
159
|
+
.use(database); // billing's `db` need has no earlier provider
|
|
160
|
+
|
|
161
|
+
defineApp('shop')
|
|
162
|
+
.use(database)
|
|
163
|
+
.use(database2); // ❌ collision: two providers of wire key 'db'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Reordering the first example — or `rename`-ing one provider in the second —
|
|
167
|
+
makes both compile. The kernel re-validates at runtime with coded errors
|
|
168
|
+
(`E012` unsatisfied need, `E013` collision, `E014` reserved key) for
|
|
169
|
+
erased/dynamic composition, with full rollback of already-booted pips.
|
|
170
|
+
|
|
171
|
+
There is no dependency graph to debug and **cycles are unrepresentable**:
|
|
172
|
+
services flow strictly forward through the `.use()` chain. If two pips need
|
|
173
|
+
each other's services, that's a design smell the type system surfaces
|
|
174
|
+
immediately — merge them, or extract the shared piece into a third pip both
|
|
175
|
+
consume.
|
|
176
|
+
|
|
177
|
+
### Tokens — cross-package service contracts
|
|
178
|
+
|
|
179
|
+
An anonymous `service<T>()` couples consumer and provider by property name.
|
|
180
|
+
When a contract spans packages, a **token** owns the wire key and the local
|
|
181
|
+
alias becomes free choice:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// The package that owns the contract exports the token:
|
|
185
|
+
export const Db = token<NodePgDatabase<Schema>>()('arki.db');
|
|
186
|
+
|
|
187
|
+
// A provider publishes under the token's key:
|
|
188
|
+
const database = pip({
|
|
189
|
+
name: 'database',
|
|
190
|
+
async boot() {
|
|
191
|
+
return provide(Db, await connect()); // → { 'arki.db': NodePgDatabase }
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Any consumer, any package, any local alias:
|
|
196
|
+
const reports = pip({
|
|
197
|
+
name: 'reports',
|
|
198
|
+
needs: { warehouse: Db }, // wire key 'arki.db', alias yours
|
|
199
|
+
async boot({ warehouse }) { /* ... */ },
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Multi-instance with `rename`
|
|
204
|
+
|
|
205
|
+
Mounting an adapter twice would collide on its wire keys — loudly, at
|
|
206
|
+
compile time. `rename` is the multi-instance primitive, and
|
|
207
|
+
`Token.instance()` derives the matching contract:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const ReportsDb = Db.instance('reports'); // token for key 'arki.db#reports'
|
|
211
|
+
|
|
212
|
+
const app = await defineApp('shop')
|
|
213
|
+
.use(database({ url: PRIMARY_URL }))
|
|
214
|
+
.use(rename(
|
|
215
|
+
database({ url: REPLICA_URL }),
|
|
216
|
+
{ 'arki.db': ReportsDb.key }, // republish under the derived key
|
|
217
|
+
'reports-db', // distinct pip name
|
|
218
|
+
))
|
|
219
|
+
.use(pip({
|
|
220
|
+
name: 'analytics',
|
|
221
|
+
needs: { primary: Db, replica: ReportsDb },
|
|
222
|
+
async boot({ primary, replica }) { /* two live instances, both typed */ },
|
|
223
|
+
}))
|
|
224
|
+
.boot();
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Renames compose left-to-right and are applied at publish time — collision
|
|
228
|
+
checks see the final keys.
|
|
229
|
+
|
|
230
|
+
### Lazy services
|
|
231
|
+
|
|
232
|
+
`lazy(init, { dispose })` publishes a **handle** instead of an open
|
|
233
|
+
resource. Initialization runs on first `get()` (memoized, single-flight;
|
|
234
|
+
failed attempts retry). Never-touched handles never initialize — and the
|
|
235
|
+
kernel auto-disposes initialized ones during teardown, *after* the
|
|
236
|
+
publishing pip's own `dispose` hook, even if that pip has none:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const search = pip({
|
|
240
|
+
name: 'search',
|
|
241
|
+
async boot() {
|
|
242
|
+
return {
|
|
243
|
+
search: lazy(() => connectToElastic(), {
|
|
244
|
+
dispose: client => client.close(),
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Consumers that shouldn't care whether a provider is eager or lazy declare a
|
|
252
|
+
**lifting witness** — `service.lazy<T>()` always delivers a `Lazy<T>`
|
|
253
|
+
handle, wrapping eager provides and passing lazy ones through by identity:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
const suggestions = pip({
|
|
257
|
+
name: 'suggestions',
|
|
258
|
+
needs: { search: service.lazy<SearchClient>() },
|
|
259
|
+
async start({ search }) {
|
|
260
|
+
const client = await search.get(); // first access initializes
|
|
261
|
+
/* ... */
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Swapping the search provider between eager and lazy is invisible to every
|
|
267
|
+
consumer — the wiring guard accepts both shapes against the same witness.
|
|
268
|
+
|
|
269
|
+
### Kernel context — the reserved `$` namespace
|
|
270
|
+
|
|
271
|
+
Every service-carrying hook context includes `$app` (app name), `$pip` (pip
|
|
272
|
+
name), and `$config` (the `defineApp(name, { config })` bag). The `$` prefix
|
|
273
|
+
is enforced as reserved: `pip()` rejects `$`-prefixed needs aliases and
|
|
274
|
+
publish keys at compile time, and the kernel re-validates at runtime
|
|
275
|
+
(`DOT_LIFECYCLE_E014`) — kernel keys can never be shadowed.
|
|
276
|
+
|
|
277
|
+
## A complex setup
|
|
278
|
+
|
|
279
|
+
A distilled commerce platform showing everything above working together.
|
|
280
|
+
(The package's stress-test suite boots a 28-pip version of this and asserts
|
|
281
|
+
exact boot/teardown ordering.)
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { defineApp, lazy, pip, provide, rename, service, token } from '@arki/dot';
|
|
285
|
+
|
|
286
|
+
// ── contracts.ts — tokens owned by their respective packages ─────────
|
|
287
|
+
export const Env = token<AppEnv>()('shop.env');
|
|
288
|
+
export const Db = token<DbHandle>()('shop.db');
|
|
289
|
+
export const ReportsDb = Db.instance('reports');
|
|
290
|
+
export const Cache = token<CacheHandle>()('shop.cache');
|
|
291
|
+
export const Bus = token<MessageBus>()('shop.bus');
|
|
292
|
+
|
|
293
|
+
// ── infrastructure pips ───────────────────────────────────────────────
|
|
294
|
+
const env = pip({
|
|
295
|
+
name: 'env',
|
|
296
|
+
boot: ({ $config }) => provide(Env, parseEnv($config)),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const telemetry = pip({
|
|
300
|
+
name: 'telemetry',
|
|
301
|
+
needs: { env: Env },
|
|
302
|
+
boot: ({ env }) => ({ metrics: makeMetrics(env.OTEL_ENDPOINT) }),
|
|
303
|
+
dispose: ({ metrics }) => metrics.flush(),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const database = (url: string) =>
|
|
307
|
+
pip({
|
|
308
|
+
name: 'database',
|
|
309
|
+
needs: { metrics: service<Metrics>() },
|
|
310
|
+
async boot({ metrics }) {
|
|
311
|
+
return provide(Db, await connectPg(url, { metrics }));
|
|
312
|
+
},
|
|
313
|
+
async dispose(ctx) {
|
|
314
|
+
await ctx[Db.key].end(); // own provide, published under the token key
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const cache = pip({
|
|
319
|
+
name: 'cache',
|
|
320
|
+
needs: { env: Env },
|
|
321
|
+
boot: ({ env }) => provide(Cache, connectRedis(env.REDIS_URL)),
|
|
322
|
+
dispose: async ctx => ctx[Cache.key].quit(),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const searchCluster = pip({
|
|
326
|
+
name: 'search-cluster',
|
|
327
|
+
needs: { env: Env },
|
|
328
|
+
boot: ({ env }) => ({
|
|
329
|
+
// Expensive external cluster — only opens if something get()s it.
|
|
330
|
+
search: lazy(() => connectElastic(env.ELASTIC_URL), {
|
|
331
|
+
dispose: client => client.close(),
|
|
332
|
+
}),
|
|
333
|
+
}),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const bus = pip({
|
|
337
|
+
name: 'bus',
|
|
338
|
+
boot: () => provide(Bus, makeInMemoryBus()),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── domain pips ───────────────────────────────────────────────────────
|
|
342
|
+
const catalog = pip({
|
|
343
|
+
name: 'catalog',
|
|
344
|
+
needs: {
|
|
345
|
+
db: Db,
|
|
346
|
+
cache: Cache,
|
|
347
|
+
search: service.lazy<SearchClient>(), // eager or lazy provider — same code
|
|
348
|
+
},
|
|
349
|
+
boot: ({ db, cache, search }) => ({
|
|
350
|
+
catalog: makeCatalog({ db, cache, search }),
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const checkout = pip({
|
|
355
|
+
name: 'checkout',
|
|
356
|
+
needs: { db: Db, bus: Bus, catalog: service<Catalog>() },
|
|
357
|
+
boot: ({ db, bus, catalog }) => ({
|
|
358
|
+
checkout: makeCheckout({ db, bus, catalog }),
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const reporting = pip({
|
|
363
|
+
name: 'reporting',
|
|
364
|
+
needs: { replica: ReportsDb, catalog: service<Catalog>() },
|
|
365
|
+
boot: ({ replica, catalog }) => ({ reporting: makeReporting(replica, catalog) }),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ── process pips — real work happens in start/stop ───────────────────
|
|
369
|
+
const outboxWorker = pip({
|
|
370
|
+
name: 'outbox-worker',
|
|
371
|
+
needs: { db: Db, bus: Bus },
|
|
372
|
+
boot: ({ db, bus }) => ({ outbox: makeOutbox(db, bus) }),
|
|
373
|
+
start: ({ outbox }) => outbox.startPolling(),
|
|
374
|
+
stop: ({ outbox }) => outbox.drain(), // stop processing, keep resources
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const http = pip({
|
|
378
|
+
name: 'http',
|
|
379
|
+
needs: {
|
|
380
|
+
env: Env,
|
|
381
|
+
catalog: service<Catalog>(),
|
|
382
|
+
checkout: service<Checkout>(),
|
|
383
|
+
reporting: service<Reporting>(),
|
|
384
|
+
},
|
|
385
|
+
async boot({ env, catalog, checkout, reporting }) {
|
|
386
|
+
return { server: buildServer({ port: env.PORT, catalog, checkout, reporting }) };
|
|
387
|
+
},
|
|
388
|
+
start: ({ server }) => server.listen(),
|
|
389
|
+
stop: ({ server }) => server.close(),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ── composition — the order IS the architecture ──────────────────────
|
|
393
|
+
const app = await defineApp('shop', { config: process.env })
|
|
394
|
+
.use(env)
|
|
395
|
+
.use(telemetry)
|
|
396
|
+
.use(database(PRIMARY_URL))
|
|
397
|
+
.use(rename(database(REPLICA_URL), { 'shop.db': ReportsDb.key }, 'reports-db'))
|
|
398
|
+
.use(cache)
|
|
399
|
+
.use(searchCluster)
|
|
400
|
+
.use(bus)
|
|
401
|
+
.use(catalog)
|
|
402
|
+
.use(checkout)
|
|
403
|
+
.use(reporting)
|
|
404
|
+
.use(outboxWorker)
|
|
405
|
+
.use(http)
|
|
406
|
+
.start();
|
|
407
|
+
|
|
408
|
+
// SIGTERM → dispose() cascades stop() first, tears down in exact reverse:
|
|
409
|
+
// http, outbox (drained), reporting, checkout, catalog, bus,
|
|
410
|
+
// search cluster (only if it ever initialized), cache, reports-db,
|
|
411
|
+
// primary db, telemetry, env.
|
|
412
|
+
process.on('SIGTERM', () => void app.dispose());
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
What the type checker is holding for you in that chain:
|
|
416
|
+
|
|
417
|
+
- move `.use(catalog)` above `.use(cache)` → compile error at that line;
|
|
418
|
+
- delete `.use(bus)` → compile errors at `checkout` and `outboxWorker`;
|
|
419
|
+
- mount the second `database(...)` without `rename` → collision error;
|
|
420
|
+
- change `makeCatalog` to return something that isn't a `Catalog` →
|
|
421
|
+
every consumer's `.use()` flags the mismatch.
|
|
422
|
+
|
|
423
|
+
Concurrent lifecycle calls are safe: transitions are serialized on one
|
|
424
|
+
queue, same-phase calls coalesce onto one in-flight promise, and a
|
|
425
|
+
`dispose()` racing a slow `start()` always runs after it — the app ends
|
|
426
|
+
`disposed`, never resurrected.
|
|
427
|
+
|
|
428
|
+
## Testing pips
|
|
429
|
+
|
|
430
|
+
`testPip` is the typed unit-test builder: satisfy a pip's needs directly
|
|
431
|
+
with fakes — no real providers, no dependency chain — and the compiler
|
|
432
|
+
holds the same line it holds in production. A missing fake means `boot()`
|
|
433
|
+
does not compile; a fake of the wrong shape fails at the `.provide()`
|
|
434
|
+
call site:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
import { testPip } from '@arki/dot/test-harness';
|
|
438
|
+
|
|
439
|
+
const app = await testPip(catalog)
|
|
440
|
+
.provide(Db, fakeDb) // token need
|
|
441
|
+
.provide('cache', fakeKv) // anonymous need — wire key is the alias
|
|
442
|
+
.boot();
|
|
443
|
+
|
|
444
|
+
expect(app.services.catalog.list()).toEqual([]);
|
|
445
|
+
await app.dispose();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The fakes are published by a synthetic first pip, so lifecycle semantics
|
|
449
|
+
are the real kernel's — reverse-order teardown, lazy auto-dispose, and
|
|
450
|
+
`$config` all behave exactly as in production. `service.lazy<T>()` needs
|
|
451
|
+
accept a plain `T` fake (lifted automatically) or `lazyOf(value)`.
|
|
452
|
+
|
|
453
|
+
For integration tests across several real pips, `testApp([...pips])`
|
|
454
|
+
builds an app from an erased pip array (runtime validation only), and
|
|
455
|
+
`bootTestApp` is the one-line boot-and-return variant.
|
|
456
|
+
|
|
457
|
+
## Operations
|
|
458
|
+
|
|
459
|
+
Two kernel helpers close the gap between "boots on my machine" and "runs
|
|
460
|
+
under a process manager":
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const app = await defineApp('shop', { hookTimeoutMs: 30_000 })
|
|
464
|
+
.use(/* ... */)
|
|
465
|
+
.start();
|
|
466
|
+
|
|
467
|
+
hookSignals(app); // SIGTERM/SIGINT → stop() + dispose() → re-raise
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
- **`hookSignals(app, { timeoutMs? })`** — graceful shutdown wiring. The
|
|
471
|
+
first signal drains the app (bounded by `timeoutMs`, default 10 s) and
|
|
472
|
+
re-raises itself so the exit status keeps standard signal semantics; a
|
|
473
|
+
second signal falls through to the runtime default (immediate kill).
|
|
474
|
+
Returns an unhook function.
|
|
475
|
+
- **`hookTimeoutMs`** — a per-hook watchdog. Any `boot`/`start`/`stop`/
|
|
476
|
+
`dispose` hook exceeding the budget fails with `DOT_LIFECYCLE_E015`
|
|
477
|
+
naming the pip and hook, and the kernel applies its normal rollback or
|
|
478
|
+
aggregation rules. Your app cannot hang silently at boot.
|
|
479
|
+
|
|
97
480
|
## Pip authoring
|
|
98
481
|
|
|
99
482
|
`pip(config)` accepts a `needs` shape plus five lifecycle hooks. Hook
|
|
@@ -106,7 +489,7 @@ contexts carry the needed services (typed, under your local aliases) and
|
|
|
106
489
|
| `boot` | Open connections; the returned record is what the pip provides. |
|
|
107
490
|
| `start` | Begin processing (workers, subscribers, schedulers). |
|
|
108
491
|
| `stop` | Stop processing in reverse declaration order. |
|
|
109
|
-
| `dispose` | Free resources after `stop
|
|
492
|
+
| `dispose` | Free resources after `stop`; lazy handles auto-clean afterwards. |
|
|
110
493
|
|
|
111
494
|
Wiring is compile-time checked: `.use(pip)` fails to typecheck when the
|
|
112
495
|
pip's needs aren't satisfied by earlier `.use()` calls, or when its
|
|
@@ -122,7 +505,7 @@ order is boot order — same input, same order, every time.
|
|
|
122
505
|
`defineApp(name)` returns a builder. Calling `.use(pip)` accumulates
|
|
123
506
|
pips. The lifecycle then flows:
|
|
124
507
|
|
|
125
|
-
```
|
|
508
|
+
```text
|
|
126
509
|
defined ──configure()──▶ configured ──boot()──▶ booted ──start()──▶ started
|
|
127
510
|
│
|
|
128
511
|
disposed ◀──dispose()── stopped ◀──stop()───────┘
|
|
@@ -131,7 +514,20 @@ defined ──configure()──▶ configured ──boot()──▶ booted ─
|
|
|
131
514
|
`boot()` runs `configure()` implicitly if you skipped it. `start()` runs
|
|
132
515
|
`boot()` implicitly. `stop()` and `dispose()` always run in reverse
|
|
133
516
|
declaration order, even when an earlier hook failed — failure isolation is
|
|
134
|
-
part of the contract.
|
|
517
|
+
part of the contract. A boot failure rolls back every already-booted pip
|
|
518
|
+
before throwing.
|
|
519
|
+
|
|
520
|
+
Runtime failures carry stable codes (`DotLifecycleError.code`):
|
|
521
|
+
|
|
522
|
+
| Code | Meaning |
|
|
523
|
+
| -------------------- | ---------------------------------------------------- |
|
|
524
|
+
| `DOT_LIFECYCLE_E011` | Pip registered twice. |
|
|
525
|
+
| `DOT_LIFECYCLE_E012` | A need has no provider among earlier pips. |
|
|
526
|
+
| `DOT_LIFECYCLE_E013` | A published wire key collides with an earlier one. |
|
|
527
|
+
| `DOT_LIFECYCLE_E014` | A service key uses the reserved `$` (kernel) prefix. |
|
|
528
|
+
| `DOT_LIFECYCLE_E015` | A hook exceeded the `hookTimeoutMs` watchdog. |
|
|
529
|
+
|
|
530
|
+
(Full table including hook-failure codes: [docs/lifecycle.md](./docs/lifecycle.md).)
|
|
135
531
|
|
|
136
532
|
## CLI
|
|
137
533
|
|
|
@@ -151,12 +547,18 @@ dot explain --app ./my-app.ts
|
|
|
151
547
|
# Run boot-time diagnostics; non-zero exit if any check fails.
|
|
152
548
|
dot doctor --app ./my-app.ts
|
|
153
549
|
|
|
550
|
+
# Render the pip graph as Mermaid flowchart source. explain shows
|
|
551
|
+
# declaration (= boot) order; doctor boots and shows the OBSERVED wiring.
|
|
552
|
+
dot doctor --app ./my-app.ts --graph
|
|
553
|
+
|
|
154
554
|
# Every command supports --json for agent-friendly output.
|
|
155
555
|
dot explain --app ./my-app.ts --json | jq '.data.pips'
|
|
156
556
|
```
|
|
157
557
|
|
|
158
558
|
The CLI emits the same envelope shape as the in-process diagnostics snapshot
|
|
159
|
-
(`app.diagnostics`), so the same downstream tools can consume either.
|
|
559
|
+
(`app.diagnostics`), so the same downstream tools can consume either. The
|
|
560
|
+
manifest's dependency edges are **observed, not declared** — the kernel
|
|
561
|
+
records which pip's published service satisfied which need during boot.
|
|
160
562
|
|
|
161
563
|
### `dot new <app-name>`
|
|
162
564
|
|
|
@@ -177,7 +579,14 @@ ship with the published tarball.
|
|
|
177
579
|
`@arki/dot` is intentionally small: it defines the contracts (pip shape,
|
|
178
580
|
lifecycle hooks, manifest schema, diagnostics envelope) and runs them. Adapters
|
|
179
581
|
that bridge databases, queues, auth providers, and HTTP routers live in their
|
|
180
|
-
own packages and consume `@arki/dot` as a peer dependency
|
|
582
|
+
own packages and consume `@arki/dot` as a peer dependency:
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
import { env } from '@arki/env/dot';
|
|
586
|
+
import { db } from '@arki/db/dot';
|
|
587
|
+
import { kv } from '@arki/kv/dot';
|
|
588
|
+
import { eventSourcing } from '@arki/event-sourcing/dot';
|
|
589
|
+
```
|
|
181
590
|
|
|
182
591
|
This keeps the kernel free of optional dependencies and lets each adapter
|
|
183
592
|
ship at its own cadence.
|
|
@@ -189,8 +598,8 @@ The full docs live in [`docs/`](./docs):
|
|
|
189
598
|
- [Principles](./docs/principles.md) — **read first.** The five rules every
|
|
190
599
|
API, error, and PR is measured against. Slightly playful, very precise.
|
|
191
600
|
- [Quickstart](./docs/quickstart.md) — boot your first app in five minutes.
|
|
192
|
-
- [Pip authoring](./docs/pip-authoring.md) — write your own
|
|
193
|
-
- [Lifecycle](./docs/lifecycle.md) — the 5-hook contract.
|
|
601
|
+
- [Pip authoring](./docs/pip-authoring.md) — write your own pip.
|
|
602
|
+
- [Lifecycle](./docs/lifecycle.md) — the 5-hook contract, idempotency, error codes.
|
|
194
603
|
- [Diagnostics](./docs/diagnostics.md) — `app.manifest`, `app.diagnostics`,
|
|
195
604
|
`dot explain`, `dot doctor`.
|
|
196
605
|
- [Adapter authoring](./docs/adapter-authoring.md) — expose your package as
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export type CliArgs = {
|
|
|
37
37
|
force?: boolean;
|
|
38
38
|
/** `--observability` (only honored by `doctor`). */
|
|
39
39
|
observability?: boolean;
|
|
40
|
+
/** `--graph` (honored by `explain` and `doctor`). */
|
|
41
|
+
graph?: boolean;
|
|
40
42
|
};
|
|
41
43
|
/**
|
|
42
44
|
* Parse argv into a typed shape. Exported so tests can exercise it without
|
|
@@ -49,6 +51,7 @@ export declare function parseArgs(argv: readonly string[]): CliArgs;
|
|
|
49
51
|
*/
|
|
50
52
|
export declare function runExplain(discovered: DiscoveredApp, opts: {
|
|
51
53
|
json: boolean;
|
|
54
|
+
graph?: boolean;
|
|
52
55
|
out?: (line: string) => void;
|
|
53
56
|
now?: () => Date;
|
|
54
57
|
}): Promise<DotCliEnvelope<unknown>>;
|
|
@@ -62,6 +65,8 @@ type DoctorRunOptions = {
|
|
|
62
65
|
* present. Default `false`.
|
|
63
66
|
*/
|
|
64
67
|
observability?: boolean;
|
|
68
|
+
/** When `true`, emit the pip graph (Mermaid) instead of diagnostics. */
|
|
69
|
+
graph?: boolean;
|
|
65
70
|
};
|
|
66
71
|
/**
|
|
67
72
|
* Run `doctor` on a discovered app. The CLI owns boot+dispose only when it
|
package/dist/cli/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAwB,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAwB,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AA+C/E,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3E,MAAM,MAAM,OAAO,GAAG;IACpB,OAAO,EAAE,UAAU,CAAC;IACpB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,EAAE,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;IAC5B,2CAA2C;IAC3C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,yCAAyC;IACzC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oDAAoD;IACpD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qDAAqD;IACrD,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAkH1D;AAQD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GACvF,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAsBlC;AAED,KAAK,gBAAgB,GAAG;IACtB,IAAI,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,UAAU,EAAE,aAAa,EACzB,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAoElC;AA0DD,KAAK,WAAW,GAAG;IACjB,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACxB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAEF;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGhE;AAeD;;GAEG;AACH,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,CAAC;AACvD,YAAY,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChF,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC"}
|
package/dist/cli/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { runNew } from './new.js';
|
|
|
25
25
|
import { probeObservability } from './observability-probe.js';
|
|
26
26
|
import { renderDoctor } from './render-doctor.js';
|
|
27
27
|
import { renderExplain } from './render-explain.js';
|
|
28
|
+
import { renderGraph } from './render-graph.js';
|
|
28
29
|
const debugCli = createDebugLogger('arki:dot:cli');
|
|
29
30
|
const VERSION = '0.1.0';
|
|
30
31
|
const HELP_TEXT = `dot — CLI for inspecting and scaffolding DOT apps
|
|
@@ -46,6 +47,10 @@ Common options:
|
|
|
46
47
|
--app <path> Path to the app entry file (default: discovers
|
|
47
48
|
./dot.config.ts, ./src/app.ts, or ./app.ts)
|
|
48
49
|
--cwd <dir> Working directory (default: current)
|
|
50
|
+
--graph Emit the pip graph as Mermaid flowchart source
|
|
51
|
+
instead of the standard output. explain shows
|
|
52
|
+
declaration (= boot) order; doctor shows the
|
|
53
|
+
wiring observed during boot. Composes with --json.
|
|
49
54
|
|
|
50
55
|
\`doctor\` options:
|
|
51
56
|
--observability Also probe whether an OpenTelemetry SDK is
|
|
@@ -116,6 +121,7 @@ export function parseArgs(argv) {
|
|
|
116
121
|
'dry-run': { type: 'boolean', default: false },
|
|
117
122
|
force: { type: 'boolean', default: false },
|
|
118
123
|
observability: { type: 'boolean', default: false },
|
|
124
|
+
graph: { type: 'boolean', default: false },
|
|
119
125
|
},
|
|
120
126
|
});
|
|
121
127
|
}
|
|
@@ -157,6 +163,7 @@ export function parseArgs(argv) {
|
|
|
157
163
|
dryRun: values['dry-run'] ?? false,
|
|
158
164
|
force: values.force ?? false,
|
|
159
165
|
observability: values.observability ?? false,
|
|
166
|
+
graph: values.graph ?? false,
|
|
160
167
|
};
|
|
161
168
|
}
|
|
162
169
|
/** Discovery wrapper — returns `null` for help/version commands. */
|
|
@@ -182,6 +189,9 @@ export async function runExplain(discovered, opts) {
|
|
|
182
189
|
catch (err) {
|
|
183
190
|
throw wrapLifecycleError(err, 'configure');
|
|
184
191
|
}
|
|
192
|
+
if (opts.graph === true) {
|
|
193
|
+
return renderGraph({ manifest: configured.manifest, command: 'explain' }, { json: opts.json, out: opts.out, now: opts.now });
|
|
194
|
+
}
|
|
185
195
|
return renderExplain({ manifest: configured.manifest }, { json: opts.json, out: opts.out, now: opts.now });
|
|
186
196
|
}
|
|
187
197
|
/**
|
|
@@ -198,6 +208,9 @@ export async function runDoctor(discovered, opts) {
|
|
|
198
208
|
// Already-booted app: just read diagnostics, don't touch lifecycle.
|
|
199
209
|
if (!guards.isDotAppBuilder(discovered) && !guards.isDotAppConfigured(discovered)) {
|
|
200
210
|
const app = discovered;
|
|
211
|
+
if (opts.graph === true) {
|
|
212
|
+
return renderGraph({ manifest: app.manifest, command: 'doctor' }, { json: opts.json, out: opts.out, now: opts.now });
|
|
213
|
+
}
|
|
201
214
|
const diagnostics = applyObservabilityProbe(app.diagnostics, opts.observability ?? false);
|
|
202
215
|
return renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
|
|
203
216
|
}
|
|
@@ -226,6 +239,13 @@ export async function runDoctor(discovered, opts) {
|
|
|
226
239
|
debugCli('boot threw, falling back to configured diagnostics: %O', err);
|
|
227
240
|
}
|
|
228
241
|
try {
|
|
242
|
+
if (opts.graph === true) {
|
|
243
|
+
// Post-boot manifest carries the observed wiring edges; after a boot
|
|
244
|
+
// failure it carries the edges recorded up to the failing pip.
|
|
245
|
+
const manifest = bootedApp ? bootedApp.manifest : configured.manifest;
|
|
246
|
+
const graphEnvelope = renderGraph({ manifest, command: 'doctor' }, { json: opts.json, out: opts.out, now: opts.now });
|
|
247
|
+
return bootThrew ? { ...graphEnvelope, status: 'failure' } : graphEnvelope;
|
|
248
|
+
}
|
|
229
249
|
const rawDiagnostics = bootedApp ? bootedApp.diagnostics : configured.diagnostics;
|
|
230
250
|
const diagnostics = applyObservabilityProbe(rawDiagnostics, opts.observability ?? false);
|
|
231
251
|
const envelope = renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
|
|
@@ -369,7 +389,7 @@ export async function main(options) {
|
|
|
369
389
|
}
|
|
370
390
|
try {
|
|
371
391
|
const discovered = await loadApp(args);
|
|
372
|
-
const opts = { json: args.json, out: stdout, now: nowFactory };
|
|
392
|
+
const opts = { json: args.json, graph: args.graph, out: stdout, now: nowFactory };
|
|
373
393
|
let envelope;
|
|
374
394
|
if (args.command === 'explain') {
|
|
375
395
|
envelope = await runExplain(discovered, opts);
|