@arki/dot 0.1.2 → 0.1.4
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 +370 -18
- package/dist/kernel/app-instance.d.ts +1 -1
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +131 -23
- 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/pip-contract.d.ts +15 -2
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js.map +1 -1
- package/package.json +1 -1
- package/src/kernel/app-instance.ts +134 -23
- package/src/lifecycle.ts +2 -0
- package/src/pip-contract.ts +19 -2
|
@@ -142,10 +142,21 @@ export class DotAppImpl {
|
|
|
142
142
|
|
|
143
143
|
/** In-flight boot promise — used for concurrent boot() coalescing. */
|
|
144
144
|
#bootInflight: Promise<void> | null = null;
|
|
145
|
+
/** In-flight start promise — used for concurrent start() coalescing. */
|
|
146
|
+
#startInflight: Promise<void> | null = null;
|
|
145
147
|
/** In-flight stop promise — used for concurrent stop() coalescing. */
|
|
146
148
|
#stopInflight: Promise<void> | null = null;
|
|
147
149
|
/** In-flight dispose promise — used for concurrent dispose() coalescing. */
|
|
148
150
|
#disposeInflight: Promise<void> | null = null;
|
|
151
|
+
/**
|
|
152
|
+
* Serializes lifecycle transitions. Each of boot/start/stop/dispose
|
|
153
|
+
* queues behind whatever transition is in flight and re-checks
|
|
154
|
+
* `#state` only once it reaches the head of the queue. Without this,
|
|
155
|
+
* phases interleave at await points — e.g. `dispose()` completes while
|
|
156
|
+
* `start()` awaits a hook, and the resuming start stamps `started`
|
|
157
|
+
* over `disposed`, resurrecting a dead app.
|
|
158
|
+
*/
|
|
159
|
+
#transitionChain: Promise<unknown> = Promise.resolve();
|
|
149
160
|
|
|
150
161
|
/**
|
|
151
162
|
* Structured lifecycle logger. One per app instance; named so consumers
|
|
@@ -188,6 +199,33 @@ export class DotAppImpl {
|
|
|
188
199
|
message: `Pip "${pip.name}" is registered twice`,
|
|
189
200
|
});
|
|
190
201
|
}
|
|
202
|
+
// `$` is the kernel context namespace ($app/$pip/$config). The pip()
|
|
203
|
+
// constraint bans these at compile time; this is the runtime backstop
|
|
204
|
+
// for erased pips and rename() targets, which types cannot see.
|
|
205
|
+
for (const alias of Object.keys(pip.needs)) {
|
|
206
|
+
if (alias.startsWith('$')) {
|
|
207
|
+
throw new DotLifecycleError({
|
|
208
|
+
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
209
|
+
phase: 'configure',
|
|
210
|
+
pip: pip.name,
|
|
211
|
+
message:
|
|
212
|
+
`Pip "${pip.name}" declares needs alias "${alias}" — the "$" prefix is reserved ` +
|
|
213
|
+
`for kernel context keys ($app, $pip, $config). Rename the alias.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const [localKey, wireKey] of Object.entries(pip.renames)) {
|
|
218
|
+
if (wireKey.startsWith('$')) {
|
|
219
|
+
throw new DotLifecycleError({
|
|
220
|
+
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
221
|
+
phase: 'configure',
|
|
222
|
+
pip: pip.name,
|
|
223
|
+
message:
|
|
224
|
+
`Pip "${pip.name}" renames "${localKey}" to "${wireKey}" — the "$" prefix is ` +
|
|
225
|
+
`reserved for kernel context keys ($app, $pip, $config). Pick a different wire key.`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
191
229
|
this.#records.set(pip.name, {
|
|
192
230
|
pip,
|
|
193
231
|
order,
|
|
@@ -533,20 +571,38 @@ export class DotAppImpl {
|
|
|
533
571
|
phaseLogger.debug('configure: complete', { 'dot.app.pip.count': this.#ordered.length });
|
|
534
572
|
}
|
|
535
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Run one lifecycle transition after all previously enqueued ones.
|
|
576
|
+
* The chain itself never rejects — each link swallows its error for
|
|
577
|
+
* the *next* link only; the caller still receives the original
|
|
578
|
+
* rejection through the returned promise.
|
|
579
|
+
*/
|
|
580
|
+
#enqueueTransition<T>(run: () => Promise<T>): Promise<T> {
|
|
581
|
+
const result = this.#transitionChain.then(run);
|
|
582
|
+
// The chain link settles even when the transition fails — the caller
|
|
583
|
+
// still sees the original rejection through `result`.
|
|
584
|
+
this.#transitionChain = result.catch(() => null);
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
587
|
+
|
|
536
588
|
/** Public boot() — idempotent + concurrent-safe. */
|
|
537
589
|
async boot(): Promise<void> {
|
|
590
|
+
if (this.#bootInflight) return this.#bootInflight;
|
|
591
|
+
this.#bootInflight = this.#enqueueTransition(() => this.#bootTransition()).finally(() => {
|
|
592
|
+
this.#bootInflight = null;
|
|
593
|
+
});
|
|
594
|
+
return this.#bootInflight;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** State checks + boot body. Runs at the head of the transition queue. */
|
|
598
|
+
async #bootTransition(): Promise<void> {
|
|
538
599
|
if (this.#state === 'failed' || this.#state === 'disposed') {
|
|
539
600
|
throw this.#reuseError('boot');
|
|
540
601
|
}
|
|
541
602
|
if (this.#state === 'booted' || this.#state === 'started' || this.#state === 'stopped') {
|
|
542
603
|
return;
|
|
543
604
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
this.#bootInflight = this.#runBoot().finally(() => {
|
|
547
|
-
this.#bootInflight = null;
|
|
548
|
-
});
|
|
549
|
-
return this.#bootInflight;
|
|
605
|
+
return this.#runBoot();
|
|
550
606
|
}
|
|
551
607
|
|
|
552
608
|
async #runBoot(): Promise<void> {
|
|
@@ -746,6 +802,22 @@ export class DotAppImpl {
|
|
|
746
802
|
|
|
747
803
|
for (const [localKey, value] of Object.entries(publishedServices)) {
|
|
748
804
|
const wireKey = pip.renames[localKey] ?? localKey;
|
|
805
|
+
if (localKey.startsWith('$') || wireKey.startsWith('$')) {
|
|
806
|
+
// The pip() constraint bans this at compile time; erased pips can
|
|
807
|
+
// still reach here. A `$` publish would shadow $app/$pip/$config
|
|
808
|
+
// in the pip's own post-boot hook contexts.
|
|
809
|
+
return fail({
|
|
810
|
+
record,
|
|
811
|
+
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
812
|
+
message:
|
|
813
|
+
`Pip "${pip.name}" publishes service "${wireKey}" — the "$" prefix is reserved ` +
|
|
814
|
+
`for kernel context keys ($app, $pip, $config).`,
|
|
815
|
+
remediation: `Rename the "${wireKey}" key returned from the boot() hook of "${pip.name}" — "$"-prefixed keys would shadow the kernel context.`,
|
|
816
|
+
docsAnchor: 'reserved-service-key',
|
|
817
|
+
durationMs: performance.now() - started,
|
|
818
|
+
rollback: [...bootedRecords, record],
|
|
819
|
+
});
|
|
820
|
+
}
|
|
749
821
|
if (this.#serviceMap.has(wireKey)) {
|
|
750
822
|
const owner = this.#providerByWireKey.get(wireKey) ?? 'unknown';
|
|
751
823
|
// The current pip HAS booted — include it in the rollback.
|
|
@@ -787,14 +859,26 @@ export class DotAppImpl {
|
|
|
787
859
|
phaseLogger.debug('boot: complete', { 'dot.app.pip.count': this.#ordered.length });
|
|
788
860
|
}
|
|
789
861
|
|
|
790
|
-
/** Public start(). Boots first if needed. Idempotent. */
|
|
862
|
+
/** Public start(). Boots first if needed. Idempotent + concurrent-safe. */
|
|
791
863
|
async start(): Promise<void> {
|
|
864
|
+
if (this.#startInflight) return this.#startInflight;
|
|
865
|
+
this.#startInflight = this.#enqueueTransition(() => this.#startTransition()).finally(() => {
|
|
866
|
+
this.#startInflight = null;
|
|
867
|
+
});
|
|
868
|
+
return this.#startInflight;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** State checks + start body. Runs at the head of the transition queue. */
|
|
872
|
+
async #startTransition(): Promise<void> {
|
|
792
873
|
if (this.#state === 'failed' || this.#state === 'disposed') {
|
|
793
874
|
throw this.#reuseError('start');
|
|
794
875
|
}
|
|
795
876
|
if (this.#state === 'started') return;
|
|
796
877
|
if (this.#state === 'defined' || this.#state === 'configured') {
|
|
797
|
-
|
|
878
|
+
// Direct #runBoot — we already hold the head of the transition
|
|
879
|
+
// queue; going through public boot() would enqueue behind
|
|
880
|
+
// ourselves and deadlock.
|
|
881
|
+
await this.#runBoot();
|
|
798
882
|
}
|
|
799
883
|
// From booted or stopped, run start hooks.
|
|
800
884
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'start');
|
|
@@ -905,24 +989,26 @@ export class DotAppImpl {
|
|
|
905
989
|
|
|
906
990
|
/** Public stop(). Idempotent + concurrent-safe. */
|
|
907
991
|
async stop(): Promise<void> {
|
|
908
|
-
if (this.#state === 'failed' || this.#state === 'disposed') {
|
|
909
|
-
// stop() after dispose is a no-op (idempotent across terminal states only
|
|
910
|
-
// for `disposed`; `failed` was already cleaned up).
|
|
911
|
-
if (this.#state === 'disposed') return;
|
|
912
|
-
throw this.#reuseError('stop');
|
|
913
|
-
}
|
|
914
992
|
if (this.#stopInflight) return this.#stopInflight;
|
|
993
|
+
this.#stopInflight = this.#enqueueTransition(() => this.#stopTransition()).finally(() => {
|
|
994
|
+
this.#stopInflight = null;
|
|
995
|
+
});
|
|
996
|
+
return this.#stopInflight;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** State checks + stop body. Runs at the head of the transition queue. */
|
|
1000
|
+
async #stopTransition(): Promise<void> {
|
|
1001
|
+
// stop() after dispose is a no-op (idempotent across terminal states only
|
|
1002
|
+
// for `disposed`; `failed` was already cleaned up).
|
|
1003
|
+
if (this.#state === 'disposed') return;
|
|
1004
|
+
if (this.#state === 'failed') throw this.#reuseError('stop');
|
|
915
1005
|
// For non-started states (defined/configured/booted/stopped): no-op.
|
|
916
1006
|
if (this.#state !== 'started') {
|
|
917
1007
|
// Mark booted as "stopped" for state-machine clarity.
|
|
918
1008
|
if (this.#state === 'booted') this.#state = 'stopped';
|
|
919
1009
|
return;
|
|
920
1010
|
}
|
|
921
|
-
|
|
922
|
-
this.#stopInflight = this.#runStop().finally(() => {
|
|
923
|
-
this.#stopInflight = null;
|
|
924
|
-
});
|
|
925
|
-
return this.#stopInflight;
|
|
1011
|
+
return this.#runStop();
|
|
926
1012
|
}
|
|
927
1013
|
|
|
928
1014
|
async #runStop(): Promise<void> {
|
|
@@ -1020,15 +1106,19 @@ export class DotAppImpl {
|
|
|
1020
1106
|
|
|
1021
1107
|
/** Public dispose(). Idempotent + concurrent-safe. */
|
|
1022
1108
|
async dispose(): Promise<void> {
|
|
1023
|
-
if (this.#state === 'disposed') return;
|
|
1024
1109
|
if (this.#disposeInflight) return this.#disposeInflight;
|
|
1025
|
-
|
|
1026
|
-
this.#disposeInflight = this.#runDispose().finally(() => {
|
|
1110
|
+
this.#disposeInflight = this.#enqueueTransition(() => this.#disposeTransition()).finally(() => {
|
|
1027
1111
|
this.#disposeInflight = null;
|
|
1028
1112
|
});
|
|
1029
1113
|
return this.#disposeInflight;
|
|
1030
1114
|
}
|
|
1031
1115
|
|
|
1116
|
+
/** State check + dispose body. Runs at the head of the transition queue. */
|
|
1117
|
+
async #disposeTransition(): Promise<void> {
|
|
1118
|
+
if (this.#state === 'disposed') return;
|
|
1119
|
+
return this.#runDispose();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1032
1122
|
async #runDispose(): Promise<void> {
|
|
1033
1123
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'dispose');
|
|
1034
1124
|
phaseLogger.debug('dispose: starting', { 'dot.app.pip.count': this.#ordered.length });
|
|
@@ -1106,9 +1196,30 @@ export class DotAppImpl {
|
|
|
1106
1196
|
|
|
1107
1197
|
async #runDisposeForRecords(records: readonly PipRecord[], phaseLogger: Logger): Promise<DotLifecyclePipFailure[]> {
|
|
1108
1198
|
const failures: DotLifecyclePipFailure[] = [];
|
|
1199
|
+
|
|
1200
|
+
// A lazy handle can be published under several keys — a `service.lazy`
|
|
1201
|
+
// consumer republishing its provider's handle passes it through by
|
|
1202
|
+
// identity. Auto-dispose belongs to the handle's FIRST publisher
|
|
1203
|
+
// (declaration order): dispose runs in reverse, so cleaning the handle
|
|
1204
|
+
// with a republisher would kill it before the original provider's own
|
|
1205
|
+
// dispose hook gets to use it. `records` arrives reversed, so plain
|
|
1206
|
+
// overwrite leaves the earliest-declared pip as each handle's owner.
|
|
1207
|
+
const ownerOf = new Map<Lazy<unknown>, PipRecord>();
|
|
1208
|
+
for (const record of records) {
|
|
1209
|
+
for (const value of Object.values(record.publishedServices)) {
|
|
1210
|
+
if (isLazy(value)) ownerOf.set(value, record);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1109
1214
|
for (const record of records) {
|
|
1215
|
+
const seen = new Set<Lazy<unknown>>();
|
|
1110
1216
|
const lazyPublishes = Object.entries(record.publishedServices).filter(
|
|
1111
|
-
(entry): entry is [string, Lazy<unknown>] =>
|
|
1217
|
+
(entry): entry is [string, Lazy<unknown>] => {
|
|
1218
|
+
const value = entry[1];
|
|
1219
|
+
if (!isLazy(value) || ownerOf.get(value) !== record || seen.has(value)) return false;
|
|
1220
|
+
seen.add(value);
|
|
1221
|
+
return true;
|
|
1222
|
+
},
|
|
1112
1223
|
);
|
|
1113
1224
|
const hasHook = record.pip.hooks.dispose !== undefined;
|
|
1114
1225
|
if (!hasHook && lazyPublishes.length === 0) {
|
package/src/lifecycle.ts
CHANGED
|
@@ -74,6 +74,8 @@ export const DotLifecycleErrorCode = {
|
|
|
74
74
|
UnsatisfiedNeed: 'DOT_LIFECYCLE_E012',
|
|
75
75
|
/** A pip published a wire key that an earlier pip already provides. */
|
|
76
76
|
ServiceCollision: 'DOT_LIFECYCLE_E013',
|
|
77
|
+
/** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
|
|
78
|
+
ReservedServiceKey: 'DOT_LIFECYCLE_E014',
|
|
77
79
|
} as const;
|
|
78
80
|
|
|
79
81
|
export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
|
package/src/pip-contract.ts
CHANGED
|
@@ -275,9 +275,23 @@ export type WireNeeds<S extends NeedsShape> = {
|
|
|
275
275
|
[K in keyof S as S[K] extends Token<unknown, infer WK> ? WK : K]: S[K] extends Service<infer T> ? T : never;
|
|
276
276
|
};
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* No `$`-prefixed keys — that prefix is the kernel context namespace
|
|
280
|
+
* ({@link KernelCtx}). Used as a constraint on needs shapes and provides
|
|
281
|
+
* records: a matching key makes the property type `never`, which no
|
|
282
|
+
* witness or service value satisfies, so the violation errors at the
|
|
283
|
+
* exact offending property. The kernel re-validates at runtime with
|
|
284
|
+
* `DOT_LIFECYCLE_E014` for paths the constraint cannot see (renames,
|
|
285
|
+
* erased pips).
|
|
286
|
+
*/
|
|
287
|
+
export type NoReservedKeys = Readonly<Record<`$${string}`, never>>;
|
|
288
|
+
|
|
278
289
|
/**
|
|
279
290
|
* Kernel-supplied context keys, present in every service-carrying hook
|
|
280
|
-
* context.
|
|
291
|
+
* context. The `$` prefix is a reserved namespace: `pip()` rejects
|
|
292
|
+
* `$`-prefixed needs aliases and publish keys at compile time (via
|
|
293
|
+
* {@link NoReservedKeys}) and the kernel enforces it at runtime
|
|
294
|
+
* (`DOT_LIFECYCLE_E014`), so these keys can never be shadowed.
|
|
281
295
|
*/
|
|
282
296
|
export type KernelCtx = {
|
|
283
297
|
/** App name (passed to `defineApp`). */
|
|
@@ -379,7 +393,10 @@ export type AnyPip = Pip<ServiceRecord, ServiceRecord>;
|
|
|
379
393
|
* });
|
|
380
394
|
* ```
|
|
381
395
|
*/
|
|
382
|
-
export function pip<
|
|
396
|
+
export function pip<
|
|
397
|
+
TShape extends NeedsShape & NoReservedKeys = EmptyShape,
|
|
398
|
+
TProvides extends ServiceRecord & NoReservedKeys = EmptyShape,
|
|
399
|
+
>(def: {
|
|
383
400
|
readonly name: string;
|
|
384
401
|
readonly version?: string;
|
|
385
402
|
readonly needs?: TShape;
|