@zhin.js/plugin-runtime 1.1.5 → 1.1.6

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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Ingress gate owned by a lifecycle Resource and switched only by SnapshotStore.
3
+ * External callbacks may enter while the owning Resource is present in the
4
+ * committed snapshot; candidate and retired Resources fail closed.
5
+ */
6
+ export declare class GenerationAdmissionGate {
7
+ constructor();
8
+ get active(): boolean;
9
+ acquire(): (() => void) | undefined;
10
+ /**
11
+ * Observe retirement of the generation that owns this gate. The callback is
12
+ * synchronous and must not fail; it is intended for closing process-owned
13
+ * ingress transports such as WebSocket connections.
14
+ */
15
+ onDeactivate(listener: () => void): () => void;
16
+ enter<T>(operation: () => T): T | undefined;
17
+ }
18
+ export declare function createGenerationAdmissionGate(): GenerationAdmissionGate;
19
+ /** Resource hook used by CapabilityContext to bind an ingress dependency. */
20
+ export declare const generationAdmissionBinder: unique symbol;
21
+ export interface GenerationAdmissionBindable<T> {
22
+ [generationAdmissionBinder](gate: GenerationAdmissionGate): T;
23
+ }
24
+ /** Snapshot value hook used to declare lifecycle-owned ingress gates. */
25
+ export declare const generationAdmissionSource: unique symbol;
26
+ export interface GenerationAdmissionSource {
27
+ readonly [generationAdmissionSource]: readonly GenerationAdmissionGate[];
28
+ }
29
+ export declare function bindGenerationAdmission<T>(value: T, gate: GenerationAdmissionGate): T;
30
+ export declare function collectGenerationAdmissions(values: Iterable<unknown>): ReadonlySet<GenerationAdmissionGate>;
31
+ export declare function replaceGenerationAdmissions(previous: ReadonlySet<GenerationAdmissionGate>, next: ReadonlySet<GenerationAdmissionGate>, owner: object, acquireNext?: () => () => void): void;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Ingress gate owned by a lifecycle Resource and switched only by SnapshotStore.
3
+ * External callbacks may enter while the owning Resource is present in the
4
+ * committed snapshot; candidate and retired Resources fail closed.
5
+ */
6
+ export class GenerationAdmissionGate {
7
+ constructor() {
8
+ admissionState.set(this, { active: false });
9
+ admissionDeactivationListeners.set(this, new Set());
10
+ }
11
+ get active() {
12
+ return admissionState.get(this)?.active === true;
13
+ }
14
+ acquire() {
15
+ const state = admissionState.get(this);
16
+ return state?.active ? state.acquire?.() : undefined;
17
+ }
18
+ /**
19
+ * Observe retirement of the generation that owns this gate. The callback is
20
+ * synchronous and must not fail; it is intended for closing process-owned
21
+ * ingress transports such as WebSocket connections.
22
+ */
23
+ onDeactivate(listener) {
24
+ const listeners = admissionDeactivationListeners.get(this);
25
+ if (!listeners)
26
+ throw new Error('Unknown generation admission gate');
27
+ if (!this.active) {
28
+ listener();
29
+ return () => undefined;
30
+ }
31
+ listeners.add(listener);
32
+ return () => { listeners.delete(listener); };
33
+ }
34
+ enter(operation) {
35
+ const release = this.acquire();
36
+ if (!release)
37
+ return undefined;
38
+ try {
39
+ const result = operation();
40
+ if (isPromiseLike(result))
41
+ return Promise.resolve(result).finally(release);
42
+ release();
43
+ return result;
44
+ }
45
+ catch (error) {
46
+ release();
47
+ throw error;
48
+ }
49
+ }
50
+ }
51
+ const admissionState = new WeakMap();
52
+ const admissionOwners = new WeakMap();
53
+ const admissionDeactivationListeners = new WeakMap();
54
+ export function createGenerationAdmissionGate() {
55
+ return new GenerationAdmissionGate();
56
+ }
57
+ /** Resource hook used by CapabilityContext to bind an ingress dependency. */
58
+ export const generationAdmissionBinder = Symbol('GenerationAdmissionBinder');
59
+ /** Snapshot value hook used to declare lifecycle-owned ingress gates. */
60
+ export const generationAdmissionSource = Symbol('GenerationAdmissionSource');
61
+ export function bindGenerationAdmission(value, gate) {
62
+ if (!isObject(value))
63
+ return value;
64
+ const binder = value[generationAdmissionBinder];
65
+ return typeof binder === 'function' ? binder.call(value, gate) : value;
66
+ }
67
+ export function collectGenerationAdmissions(values) {
68
+ const result = new Set();
69
+ for (const value of values) {
70
+ if (value instanceof GenerationAdmissionGate) {
71
+ result.add(value);
72
+ continue;
73
+ }
74
+ if (!isObject(value))
75
+ continue;
76
+ const gates = value[generationAdmissionSource];
77
+ if (!Array.isArray(gates))
78
+ continue;
79
+ for (const gate of gates) {
80
+ if (!(gate instanceof GenerationAdmissionGate)) {
81
+ throw new TypeError('Generation admission source returned an invalid gate');
82
+ }
83
+ result.add(gate);
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ export function replaceGenerationAdmissions(previous, next, owner, acquireNext) {
89
+ // Validate the complete switch before changing ownership or visibility. A
90
+ // rejected cross-Root gate must leave both generations exactly untouched.
91
+ for (const gate of next)
92
+ assertClaimableAdmission(gate, owner);
93
+ for (const gate of previous) {
94
+ assertAdmissionOwner(gate, owner);
95
+ }
96
+ for (const gate of next)
97
+ admissionOwners.set(gate, owner);
98
+ for (const gate of previous) {
99
+ if (next.has(gate))
100
+ continue;
101
+ admissionState.set(gate, { active: false });
102
+ for (const listener of admissionDeactivationListeners.get(gate) ?? []) {
103
+ try {
104
+ listener();
105
+ }
106
+ catch {
107
+ // Admission publication is an infallible pointer switch. Transport
108
+ // cleanup reports through its own lifecycle and cannot veto commit.
109
+ }
110
+ }
111
+ }
112
+ for (const gate of next) {
113
+ admissionState.set(gate, { active: true, acquire: acquireNext });
114
+ }
115
+ }
116
+ function assertClaimableAdmission(gate, owner) {
117
+ const current = admissionOwners.get(gate);
118
+ if (current && current !== owner) {
119
+ throw new Error('Generation admission gate belongs to another SnapshotStore');
120
+ }
121
+ }
122
+ function assertAdmissionOwner(gate, owner) {
123
+ if (admissionOwners.get(gate) !== owner) {
124
+ throw new Error('SnapshotStore does not own generation admission gate');
125
+ }
126
+ }
127
+ function isObject(value) {
128
+ return (typeof value === 'object' && value !== null) || typeof value === 'function';
129
+ }
130
+ function isPromiseLike(value) {
131
+ return isObject(value)
132
+ && typeof value.then === 'function';
133
+ }
package/lib/handoff.d.ts CHANGED
@@ -1,30 +1,24 @@
1
- import type { RuntimeSnapshot } from './snapshot.js';
2
1
  export interface GenerationHandoff {
3
- quiescePrevious(previous: RuntimeSnapshot): void | Promise<void>;
4
- activateNext(): void | Promise<void>;
2
+ activateNext(signal: AbortSignal): void | Promise<void>;
5
3
  deactivateNext(): void | Promise<void>;
6
- resumePrevious(): void | Promise<void>;
7
- openNext(): void;
8
4
  }
9
5
  export interface GenerationHandoffParticipant {
10
- quiescePrevious?(previous: RuntimeSnapshot): void | Promise<void>;
11
- activateNext?(): void | Promise<void>;
6
+ activateNext?(signal: AbortSignal): void | Promise<void>;
12
7
  deactivateNext?(): void | Promise<void>;
13
- resumePrevious?(): void | Promise<void>;
14
- openNext?(): void;
15
8
  }
16
9
  export interface GenerationHandoffRegistry {
17
10
  add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
18
11
  }
12
+ /** A transaction failed and its internal compensation also failed. */
13
+ export declare class GenerationCompensationError extends AggregateError {
14
+ constructor(errors: readonly unknown[], message: string, options?: ErrorOptions);
15
+ }
19
16
  /** Composes owner-ordered Resource handoffs and compensates partial progress. */
20
17
  export declare class GenerationHandoffStack implements GenerationHandoffRegistry, GenerationHandoff {
21
18
  #private;
22
19
  get size(): number;
23
20
  add(participant: GenerationHandoffParticipant): GenerationHandoffParticipant;
24
21
  seal(): GenerationHandoff | undefined;
25
- quiescePrevious(previous: RuntimeSnapshot): Promise<void>;
26
- activateNext(): Promise<void>;
22
+ activateNext(signal: AbortSignal): Promise<void>;
27
23
  deactivateNext(): Promise<void>;
28
- resumePrevious(): Promise<void>;
29
- openNext(): void;
30
24
  }
package/lib/handoff.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { DisposeStack } from './dispose.js';
2
+ /** A transaction failed and its internal compensation also failed. */
3
+ export class GenerationCompensationError extends AggregateError {
4
+ constructor(errors, message, options) {
5
+ super(errors, message, options);
6
+ this.name = 'GenerationCompensationError';
7
+ }
8
+ }
2
9
  /** Composes owner-ordered Resource handoffs and compensates partial progress. */
3
10
  export class GenerationHandoffStack {
4
11
  #participants = [];
5
- #quiesced = [];
6
12
  #activated = [];
7
13
  #sealed = false;
8
14
  get size() {
@@ -18,27 +24,13 @@ export class GenerationHandoffStack {
18
24
  this.#sealed = true;
19
25
  return this.size > 0 ? this : undefined;
20
26
  }
21
- async quiescePrevious(previous) {
22
- this.#assertSealed();
23
- try {
24
- for (const participant of [...this.#participants].reverse()) {
25
- if (!participant.quiescePrevious)
26
- continue;
27
- await participant.quiescePrevious(previous);
28
- this.#quiesced.push(participant);
29
- }
30
- }
31
- catch (error) {
32
- await compensate(error, () => this.resumePrevious(), 'Quiesce and resume both failed');
33
- }
34
- }
35
- async activateNext() {
27
+ async activateNext(signal) {
36
28
  this.#assertSealed();
37
29
  try {
38
30
  for (const participant of this.#participants) {
39
31
  if (!participant.activateNext)
40
32
  continue;
41
- await participant.activateNext();
33
+ await participant.activateNext(signal);
42
34
  this.#activated.push(participant);
43
35
  }
44
36
  }
@@ -49,22 +41,6 @@ export class GenerationHandoffStack {
49
41
  async deactivateNext() {
50
42
  await unwind(this.#activated, (participant) => participant.deactivateNext?.(), 'One or more next-generation Resources failed to deactivate');
51
43
  }
52
- async resumePrevious() {
53
- await unwind(this.#quiesced, (participant) => participant.resumePrevious?.(), 'One or more previous-generation Resources failed to resume');
54
- }
55
- openNext() {
56
- const errors = [];
57
- for (const participant of this.#participants) {
58
- try {
59
- participant.openNext?.();
60
- }
61
- catch (error) {
62
- errors.push(error);
63
- }
64
- }
65
- if (errors.length > 0)
66
- throw new AggregateError(errors, 'One or more Resources failed to open');
67
- }
68
44
  #assertSealed() {
69
45
  if (!this.#sealed)
70
46
  throw new Error('GenerationHandoffStack is not sealed');
@@ -75,7 +51,7 @@ async function compensate(primary, rollback, message) {
75
51
  await rollback();
76
52
  }
77
53
  catch (rollbackError) {
78
- throw new AggregateError([primary, rollbackError], message, { cause: rollbackError });
54
+ throw new GenerationCompensationError([primary, rollbackError], message, { cause: rollbackError });
79
55
  }
80
56
  throw primary;
81
57
  }
package/lib/index.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './capability.js';
3
3
  export * from './dispose.js';
4
4
  export * from './identity.js';
5
+ export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionBindable, type GenerationAdmissionSource, } from './admission.js';
5
6
  export * from './generation-store.js';
6
7
  export * from './handoff.js';
7
8
  /** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './capability.js';
3
3
  export * from './dispose.js';
4
4
  export * from './identity.js';
5
+ export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, } from './admission.js';
5
6
  export * from './generation-store.js';
6
7
  export * from './handoff.js';
7
8
  /** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
@@ -26,7 +26,7 @@ export interface OutboundSendInput {
26
26
  export interface OutboundReactionInput {
27
27
  readonly adapter: string;
28
28
  readonly endpointKey: string;
29
- readonly messageId: string;
29
+ readonly message: OutboundMessage;
30
30
  readonly emoji: string;
31
31
  readonly sceneType?: string;
32
32
  readonly channelId?: string;
@@ -34,13 +34,23 @@ export interface OutboundReactionInput {
34
34
  export interface OutboundRemoveReactionInput {
35
35
  readonly adapter: string;
36
36
  readonly endpointKey: string;
37
- readonly messageId: string;
37
+ readonly message: OutboundMessage;
38
38
  readonly reactionId: string;
39
39
  }
40
40
  export interface OutboundRecallInput {
41
41
  readonly adapter: string;
42
42
  readonly endpointKey: string;
43
- readonly messageId: string;
43
+ readonly message: OutboundMessage;
44
+ }
45
+ /** Structured message identity; structurally compatible with IM MessageRef. */
46
+ export interface OutboundMessage {
47
+ readonly conversation: OutboundConversation & Readonly<{
48
+ readonly endpoint: Readonly<{
49
+ readonly id: string;
50
+ readonly adapter: string;
51
+ }>;
52
+ }>;
53
+ readonly id: string;
44
54
  }
45
55
  export interface OutboundHost {
46
56
  /** Returns platform message id when available (activity-feedback needs it). */
package/lib/plugin.d.ts CHANGED
@@ -18,6 +18,7 @@ export interface PluginInstanceView {
18
18
  readonly role: 'root' | 'child';
19
19
  }
20
20
  export interface PluginSetupContext<TConfig = unknown> {
21
+ readonly signal: AbortSignal;
21
22
  readonly plugin: PluginInstanceView;
22
23
  readonly config: ConfigView<TConfig>;
23
24
  readonly resources: Scope;
@@ -1,21 +1,25 @@
1
1
  import type { PluginId } from './identity.js';
2
- import { SnapshotStore, type PreparedGeneration, type RuntimeSnapshot, type SnapshotState } from './snapshot.js';
2
+ import { type PreparedGeneration, type RuntimeSnapshot, type SnapshotReader, type SnapshotState } from './snapshot.js';
3
3
  export type RootState = 'idle' | 'running' | 'stopping' | 'stopped' | 'failed';
4
- export type PrepareGeneration = (current: RuntimeSnapshot) => PreparedGeneration | Promise<PreparedGeneration>;
5
- export type PrepareTransaction = (current: RuntimeSnapshot) => PreparedGeneration | undefined | Promise<PreparedGeneration | undefined>;
4
+ export type PrepareGeneration = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration | Promise<PreparedGeneration>;
5
+ export type PrepareTransaction = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration | undefined | Promise<PreparedGeneration | undefined>;
6
6
  export type ControlErrorHandler = (error: unknown) => void;
7
7
  export interface GenerationCommitEvent {
8
8
  readonly previous: RuntimeSnapshot;
9
9
  readonly current: RuntimeSnapshot;
10
10
  }
11
11
  export type GenerationCommitListener = (event: GenerationCommitEvent) => void;
12
+ export declare class RootIntegrityError extends Error {
13
+ constructor(message: string, options?: ErrorOptions);
14
+ }
12
15
  export declare class RootController {
13
16
  #private;
14
17
  private readonly onControlError;
15
- readonly snapshots: SnapshotStore;
18
+ readonly snapshots: SnapshotReader;
16
19
  constructor(initial: SnapshotState, onControlError?: ControlErrorHandler);
17
20
  get state(): RootState;
18
21
  get generation(): number;
22
+ get snapshot(): RuntimeSnapshot;
19
23
  /** Observe successful generation commits, including the initial start. */
20
24
  onGenerationCommit(listener: GenerationCommitListener): () => void;
21
25
  start(prepare: PrepareGeneration): Promise<RuntimeSnapshot>;
@@ -1,19 +1,43 @@
1
+ import { GenerationCompensationError } from './handoff.js';
1
2
  import { SnapshotStore, } from './snapshot.js';
3
+ export class RootIntegrityError extends Error {
4
+ constructor(message, options) {
5
+ super(message, options);
6
+ this.name = 'RootIntegrityError';
7
+ }
8
+ }
2
9
  export class RootController {
3
10
  onControlError;
4
11
  snapshots;
12
+ #snapshotStore;
5
13
  #state = 'idle';
6
14
  #tail = Promise.resolve();
15
+ #stopResult;
16
+ #stopRequested = false;
17
+ #activeTransaction;
7
18
  #generationCommitListeners = new Set();
8
19
  constructor(initial, onControlError = () => undefined) {
9
20
  this.onControlError = onControlError;
10
- this.snapshots = new SnapshotStore(initial);
21
+ this.#snapshotStore = new SnapshotStore(initial, (error) => this.#failIntegrity(error));
22
+ const store = this.#snapshotStore;
23
+ this.snapshots = Object.freeze({
24
+ acquire: () => {
25
+ if (this.#state !== 'running' || this.#stopRequested) {
26
+ throw new RootIntegrityError(`Root is not accepting generation operations (${this.#state})`);
27
+ }
28
+ return store.acquire();
29
+ },
30
+ owns: (lease) => store.owns(lease),
31
+ });
11
32
  }
12
33
  get state() {
13
34
  return this.#state;
14
35
  }
15
36
  get generation() {
16
- return this.snapshots.current.generation;
37
+ return this.#snapshotStore.current.generation;
38
+ }
39
+ get snapshot() {
40
+ return this.#snapshotStore.current;
17
41
  }
18
42
  /** Observe successful generation commits, including the initial start. */
19
43
  onGenerationCommit(listener) {
@@ -29,12 +53,15 @@ export class RootController {
29
53
  }
30
54
  let prepared;
31
55
  let activated = false;
56
+ const transaction = new AbortController();
57
+ this.#activeTransaction = transaction;
32
58
  try {
33
- prepared = await prepare(this.snapshots.current);
59
+ prepared = await prepare(this.#snapshotStore.current, transaction.signal);
34
60
  if (prepared.handoff) {
35
61
  activated = true;
36
- await prepared.handoff.activateNext();
62
+ await prepared.handoff.activateNext(transaction.signal);
37
63
  }
64
+ this.#assertPublishable('idle');
38
65
  this.#state = 'running';
39
66
  const snapshot = this.#commitGeneration(0, prepared);
40
67
  prepared = undefined;
@@ -44,6 +71,10 @@ export class RootController {
44
71
  this.#state = 'failed';
45
72
  return this.#rollback(prepared, { activated }, error);
46
73
  }
74
+ finally {
75
+ if (this.#activeTransaction === transaction)
76
+ this.#activeTransaction = undefined;
77
+ }
47
78
  });
48
79
  }
49
80
  transact(prepare) {
@@ -53,26 +84,28 @@ export class RootController {
53
84
  }
54
85
  const previous = this.snapshots.acquire();
55
86
  let prepared;
56
- let quiesced = false;
57
87
  let activated = false;
88
+ const transaction = new AbortController();
89
+ this.#activeTransaction = transaction;
58
90
  try {
59
- prepared = await prepare(previous.value);
91
+ prepared = await prepare(previous.value, transaction.signal);
60
92
  if (!prepared)
61
93
  return previous.value;
62
94
  if (prepared.handoff) {
63
- quiesced = true;
64
- await prepared.handoff.quiescePrevious(previous.value);
65
95
  activated = true;
66
- await prepared.handoff.activateNext();
96
+ await prepared.handoff.activateNext(transaction.signal);
67
97
  }
98
+ this.#assertPublishable('running');
68
99
  const snapshot = this.#commitGeneration(previous.value.generation, prepared);
69
100
  prepared = undefined;
70
101
  return snapshot;
71
102
  }
72
103
  catch (error) {
73
- return this.#rollback(prepared, { quiesced, activated }, error);
104
+ return this.#rollback(prepared, { activated }, error);
74
105
  }
75
106
  finally {
107
+ if (this.#activeTransaction === transaction)
108
+ this.#activeTransaction = undefined;
76
109
  previous.release();
77
110
  }
78
111
  });
@@ -81,16 +114,26 @@ export class RootController {
81
114
  return this.transact(prepare);
82
115
  }
83
116
  stop() {
84
- return this.#enqueue(async () => {
117
+ if (this.#stopResult)
118
+ return this.#stopResult;
119
+ this.#stopRequested = true;
120
+ this.#activeTransaction?.abort(new Error('Root lifecycle is stopping'));
121
+ const result = this.#enqueue(async () => {
85
122
  if (this.#state === 'stopped')
86
123
  return;
87
124
  if (this.#state !== 'running' && this.#state !== 'failed') {
88
125
  throw new Error(`Cannot stop RootController from ${this.#state}`);
89
126
  }
90
127
  this.#state = 'stopping';
91
- await this.snapshots.close();
92
- this.#state = 'stopped';
128
+ try {
129
+ await this.#snapshotStore.close();
130
+ }
131
+ finally {
132
+ this.#state = 'stopped';
133
+ }
93
134
  });
135
+ this.#stopResult = result;
136
+ return result;
94
137
  }
95
138
  #enqueue(operation) {
96
139
  const result = this.#tail.then(operation, operation);
@@ -98,9 +141,8 @@ export class RootController {
98
141
  return result;
99
142
  }
100
143
  #commitGeneration(expectedGeneration, prepared) {
101
- const previous = this.snapshots.current;
102
- const snapshot = this.snapshots.commit(expectedGeneration, prepared);
103
- this.#openNext(prepared.handoff);
144
+ const previous = this.#snapshotStore.current;
145
+ const snapshot = this.#snapshotStore.commit(expectedGeneration, prepared);
104
146
  const event = Object.freeze({ previous, current: snapshot });
105
147
  const listeners = [...this.#generationCommitListeners];
106
148
  queueMicrotask(() => {
@@ -115,14 +157,6 @@ export class RootController {
115
157
  });
116
158
  return snapshot;
117
159
  }
118
- #openNext(handoff) {
119
- try {
120
- handoff?.openNext();
121
- }
122
- catch (error) {
123
- this.#reportControlError(error);
124
- }
125
- }
126
160
  #reportControlError(error) {
127
161
  try {
128
162
  this.onControlError(error);
@@ -141,14 +175,6 @@ export class RootController {
141
175
  errors.push(error);
142
176
  }
143
177
  }
144
- if (prepared && state.quiesced) {
145
- try {
146
- await prepared.handoff?.resumePrevious();
147
- }
148
- catch (error) {
149
- errors.push(error);
150
- }
151
- }
152
178
  if (prepared) {
153
179
  try {
154
180
  await prepared.dispose();
@@ -157,9 +183,22 @@ export class RootController {
157
183
  errors.push(error);
158
184
  }
159
185
  }
160
- if (errors.length > 1) {
161
- throw new AggregateError(errors, 'Generation transaction and rollback both failed');
186
+ if (errors.length > 1 || transactionError instanceof GenerationCompensationError) {
187
+ const aggregate = new AggregateError(errors, 'Generation transaction and rollback both failed');
188
+ this.#failIntegrity(aggregate);
189
+ throw new RootIntegrityError('Root integrity failed while rolling back a generation transaction', { cause: aggregate });
162
190
  }
163
191
  throw transactionError;
164
192
  }
193
+ #failIntegrity(error) {
194
+ if (this.#state === 'stopped' || this.#state === 'stopping')
195
+ return;
196
+ this.#state = 'failed';
197
+ this.#reportControlError(error);
198
+ }
199
+ #assertPublishable(expected) {
200
+ if (this.#state !== expected || this.#stopRequested) {
201
+ throw new RootIntegrityError(`Root cannot publish a generation after admission entered ${this.#stopRequested ? 'stopping' : this.#state}`);
202
+ }
203
+ }
165
204
  }
package/lib/snapshot.d.ts CHANGED
@@ -41,9 +41,15 @@ export declare class SnapshotLease {
41
41
  get active(): boolean;
42
42
  release(): void;
43
43
  }
44
- export declare class SnapshotStore {
44
+ /** Read-only operation seam exposed by a Root lifecycle. */
45
+ export interface SnapshotReader {
46
+ acquire(): SnapshotLease;
47
+ owns(lease: SnapshotLease): boolean;
48
+ }
49
+ export declare class SnapshotStore implements SnapshotReader {
45
50
  #private;
46
- constructor(initial: SnapshotState);
51
+ private readonly onDisposalError;
52
+ constructor(initial: SnapshotState, onDisposalError?: (error: unknown) => void);
47
53
  get current(): RuntimeSnapshot;
48
54
  acquire(): SnapshotLease;
49
55
  owns(lease: SnapshotLease): boolean;
package/lib/snapshot.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { collectGenerationAdmissions, replaceGenerationAdmissions, } from './admission.js';
1
2
  export class GenerationConflictError extends Error {
2
3
  expected;
3
4
  actual;
@@ -30,17 +31,24 @@ export class SnapshotLease {
30
31
  }
31
32
  }
32
33
  export class SnapshotStore {
34
+ onDisposalError;
33
35
  #current;
34
36
  #retired = new Set();
35
37
  #leases = new WeakSet();
36
38
  #closed = false;
37
- constructor(initial) {
38
- this.#current = {
39
- snapshot: createSnapshotView(0, initial),
39
+ constructor(initial, onDisposalError = () => undefined) {
40
+ this.onDisposalError = onDisposalError;
41
+ const snapshot = createSnapshotView(0, initial);
42
+ const admissions = collectSnapshotAdmissions(snapshot);
43
+ const record = {
44
+ snapshot,
40
45
  dispose: () => undefined,
46
+ admissions,
41
47
  leases: 0,
42
48
  retired: false,
43
49
  };
50
+ replaceGenerationAdmissions(new Set(), admissions, this, () => this.#retain(record));
51
+ this.#current = record;
44
52
  }
45
53
  get current() {
46
54
  return this.#current.snapshot;
@@ -49,11 +57,7 @@ export class SnapshotStore {
49
57
  if (this.#closed)
50
58
  throw new Error('SnapshotStore is closed');
51
59
  const record = this.#current;
52
- record.leases += 1;
53
- const lease = new SnapshotLease(record.snapshot, () => {
54
- record.leases -= 1;
55
- this.#disposeIfReady(record);
56
- }, snapshotLeaseAuthority);
60
+ const lease = new SnapshotLease(record.snapshot, this.#retain(record), snapshotLeaseAuthority);
57
61
  this.#leases.add(lease);
58
62
  return lease;
59
63
  }
@@ -70,14 +74,19 @@ export class SnapshotStore {
70
74
  throw new GenerationConflictError(expectedGeneration, this.#current.snapshot.generation);
71
75
  }
72
76
  const previous = this.#current;
73
- // Commit only swaps the active pointer. The previous record may still be
74
- // serving in-flight work, so disposal is deferred to its final lease.
75
- this.#current = {
76
- snapshot: createSnapshotView(expectedGeneration + 1, prepared.snapshot),
77
+ const snapshot = createSnapshotView(expectedGeneration + 1, prepared.snapshot);
78
+ const admissions = collectSnapshotAdmissions(snapshot);
79
+ const next = {
80
+ snapshot,
77
81
  dispose: prepared.dispose,
82
+ admissions,
78
83
  leases: 0,
79
84
  retired: false,
80
85
  };
86
+ replaceGenerationAdmissions(previous.admissions, admissions, this, () => this.#retain(next));
87
+ // Commit only swaps the active pointer. The previous record may still be
88
+ // serving in-flight work, so disposal is deferred to its final lease.
89
+ this.#current = next;
81
90
  previous.retired = true;
82
91
  this.#retired.add(previous);
83
92
  this.#disposeIfReady(previous);
@@ -87,6 +96,7 @@ export class SnapshotStore {
87
96
  if (this.#closed)
88
97
  return;
89
98
  this.#closed = true;
99
+ replaceGenerationAdmissions(this.#current.admissions, new Set(), this);
90
100
  this.#current.retired = true;
91
101
  this.#retired.add(this.#current);
92
102
  this.#disposeIfReady(this.#current);
@@ -100,9 +110,25 @@ export class SnapshotStore {
100
110
  return;
101
111
  if (!record.disposing) {
102
112
  record.disposing = Promise.resolve().then(record.dispose);
103
- void record.disposing.then(() => record.drain?.resolve(), (error) => record.drain?.reject(error));
113
+ void record.disposing.then(() => record.drain?.resolve(), (error) => {
114
+ record.drain?.reject(error);
115
+ this.onDisposalError(error);
116
+ });
104
117
  }
105
118
  }
119
+ #retain(record) {
120
+ record.leases += 1;
121
+ let released = false;
122
+ return () => {
123
+ if (released)
124
+ return;
125
+ released = true;
126
+ record.leases -= 1;
127
+ if (record.leases < 0)
128
+ throw new Error('Snapshot lease underflow');
129
+ this.#disposeIfReady(record);
130
+ };
131
+ }
106
132
  #waitForDisposal(record) {
107
133
  if (record.disposing)
108
134
  return record.disposing;
@@ -118,6 +144,12 @@ export class SnapshotStore {
118
144
  return record.drain.promise;
119
145
  }
120
146
  }
147
+ function collectSnapshotAdmissions(snapshot) {
148
+ return collectGenerationAdmissions([
149
+ ...snapshot.projections.values(),
150
+ ...[...snapshot.resources.values()].flatMap((resources) => [...resources.values()]),
151
+ ]);
152
+ }
121
153
  export function createSnapshotView(generation, state) {
122
154
  // Object.freeze(Map) does not disable Map#set. ReadonlyMapView removes the
123
155
  // mutator interface at runtime, including from provider projection hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-runtime",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",