@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.
@@ -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
- if (this.#bootInflight) return this.#bootInflight;
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
- await this.boot();
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>] => isLazy(entry[1]),
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];
@@ -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. `$`-prefixed so they can never collide with service aliases.
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<TShape extends NeedsShape = EmptyShape, TProvides extends ServiceRecord = EmptyShape>(def: {
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;