@zhin.js/plugin-runtime 1.1.5 → 1.1.7

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,25 +1,30 @@
1
1
  import type { PluginId } from './identity.js';
2
- import { SnapshotStore, type PreparedGeneration, type RuntimeSnapshot, type SnapshotState } from './snapshot.js';
2
+ import { type CommittedGeneration, 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<TState = undefined> = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration<TState> | Promise<PreparedGeneration<TState>>;
5
+ export type PrepareTransaction<TState = undefined> = (current: RuntimeSnapshot, signal: AbortSignal) => PreparedGeneration<TState> | undefined | Promise<PreparedGeneration<TState> | undefined>;
6
6
  export type ControlErrorHandler = (error: unknown) => void;
7
- export interface GenerationCommitEvent {
8
- readonly previous: RuntimeSnapshot;
9
- readonly current: RuntimeSnapshot;
7
+ export interface GenerationCommitEvent<TState = undefined> {
8
+ readonly previous: CommittedGeneration<TState>;
9
+ readonly current: CommittedGeneration<TState>;
10
10
  }
11
- export type GenerationCommitListener = (event: GenerationCommitEvent) => void;
12
- export declare class RootController {
11
+ export type GenerationCommitListener<TState = undefined> = (event: GenerationCommitEvent<TState>) => void | Promise<void>;
12
+ export declare class RootIntegrityError extends Error {
13
+ constructor(message: string, options?: ErrorOptions);
14
+ }
15
+ export declare class RootController<TState = undefined> {
13
16
  #private;
14
17
  private readonly onControlError;
15
- readonly snapshots: SnapshotStore;
16
- constructor(initial: SnapshotState, onControlError?: ControlErrorHandler);
18
+ readonly snapshots: SnapshotReader;
19
+ constructor(initial: SnapshotState, onControlError?: ControlErrorHandler, ...state: [TState] extends [undefined] ? [initialState?: undefined] : [initialState: TState]);
17
20
  get state(): RootState;
18
21
  get generation(): number;
22
+ get snapshot(): RuntimeSnapshot;
23
+ get committed(): CommittedGeneration<TState>;
19
24
  /** Observe successful generation commits, including the initial start. */
20
- onGenerationCommit(listener: GenerationCommitListener): () => void;
21
- start(prepare: PrepareGeneration): Promise<RuntimeSnapshot>;
22
- transact(prepare: PrepareTransaction): Promise<RuntimeSnapshot>;
23
- reload(_target: PluginId | string, prepare: PrepareTransaction): Promise<RuntimeSnapshot>;
25
+ onGenerationCommit(listener: GenerationCommitListener<TState>): () => void;
26
+ start(prepare: PrepareGeneration<TState>): Promise<RuntimeSnapshot>;
27
+ transact(prepare: PrepareTransaction<TState>): Promise<RuntimeSnapshot>;
28
+ reload(_target: PluginId | string, prepare: PrepareTransaction<TState>): Promise<RuntimeSnapshot>;
24
29
  stop(): Promise<void>;
25
30
  }
@@ -1,19 +1,46 @@
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
- constructor(initial, onControlError = () => undefined) {
19
+ constructor(initial, onControlError = () => undefined, ...state) {
9
20
  this.onControlError = onControlError;
10
- this.snapshots = new SnapshotStore(initial);
21
+ this.#snapshotStore = new SnapshotStore(initial, (error) => this.#failIntegrity(error), ...state);
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;
41
+ }
42
+ get committed() {
43
+ return this.#snapshotStore.committed;
17
44
  }
18
45
  /** Observe successful generation commits, including the initial start. */
19
46
  onGenerationCommit(listener) {
@@ -29,12 +56,15 @@ export class RootController {
29
56
  }
30
57
  let prepared;
31
58
  let activated = false;
59
+ const transaction = new AbortController();
60
+ this.#activeTransaction = transaction;
32
61
  try {
33
- prepared = await prepare(this.snapshots.current);
62
+ prepared = await prepare(this.#snapshotStore.current, transaction.signal);
34
63
  if (prepared.handoff) {
35
64
  activated = true;
36
- await prepared.handoff.activateNext();
65
+ await prepared.handoff.activateNext(transaction.signal);
37
66
  }
67
+ this.#assertPublishable('idle');
38
68
  this.#state = 'running';
39
69
  const snapshot = this.#commitGeneration(0, prepared);
40
70
  prepared = undefined;
@@ -44,6 +74,10 @@ export class RootController {
44
74
  this.#state = 'failed';
45
75
  return this.#rollback(prepared, { activated }, error);
46
76
  }
77
+ finally {
78
+ if (this.#activeTransaction === transaction)
79
+ this.#activeTransaction = undefined;
80
+ }
47
81
  });
48
82
  }
49
83
  transact(prepare) {
@@ -53,26 +87,28 @@ export class RootController {
53
87
  }
54
88
  const previous = this.snapshots.acquire();
55
89
  let prepared;
56
- let quiesced = false;
57
90
  let activated = false;
91
+ const transaction = new AbortController();
92
+ this.#activeTransaction = transaction;
58
93
  try {
59
- prepared = await prepare(previous.value);
94
+ prepared = await prepare(previous.value, transaction.signal);
60
95
  if (!prepared)
61
96
  return previous.value;
62
97
  if (prepared.handoff) {
63
- quiesced = true;
64
- await prepared.handoff.quiescePrevious(previous.value);
65
98
  activated = true;
66
- await prepared.handoff.activateNext();
99
+ await prepared.handoff.activateNext(transaction.signal);
67
100
  }
101
+ this.#assertPublishable('running');
68
102
  const snapshot = this.#commitGeneration(previous.value.generation, prepared);
69
103
  prepared = undefined;
70
104
  return snapshot;
71
105
  }
72
106
  catch (error) {
73
- return this.#rollback(prepared, { quiesced, activated }, error);
107
+ return this.#rollback(prepared, { activated }, error);
74
108
  }
75
109
  finally {
110
+ if (this.#activeTransaction === transaction)
111
+ this.#activeTransaction = undefined;
76
112
  previous.release();
77
113
  }
78
114
  });
@@ -81,16 +117,26 @@ export class RootController {
81
117
  return this.transact(prepare);
82
118
  }
83
119
  stop() {
84
- return this.#enqueue(async () => {
120
+ if (this.#stopResult)
121
+ return this.#stopResult;
122
+ this.#stopRequested = true;
123
+ this.#activeTransaction?.abort(new Error('Root lifecycle is stopping'));
124
+ const result = this.#enqueue(async () => {
85
125
  if (this.#state === 'stopped')
86
126
  return;
87
127
  if (this.#state !== 'running' && this.#state !== 'failed') {
88
128
  throw new Error(`Cannot stop RootController from ${this.#state}`);
89
129
  }
90
130
  this.#state = 'stopping';
91
- await this.snapshots.close();
92
- this.#state = 'stopped';
131
+ try {
132
+ await this.#snapshotStore.close();
133
+ }
134
+ finally {
135
+ this.#state = 'stopped';
136
+ }
93
137
  });
138
+ this.#stopResult = result;
139
+ return result;
94
140
  }
95
141
  #enqueue(operation) {
96
142
  const result = this.#tail.then(operation, operation);
@@ -98,30 +144,23 @@ export class RootController {
98
144
  return result;
99
145
  }
100
146
  #commitGeneration(expectedGeneration, prepared) {
101
- const previous = this.snapshots.current;
102
- const snapshot = this.snapshots.commit(expectedGeneration, prepared);
103
- this.#openNext(prepared.handoff);
104
- const event = Object.freeze({ previous, current: snapshot });
147
+ const previous = this.#snapshotStore.committed;
148
+ const current = this.#snapshotStore.commit(expectedGeneration, prepared);
149
+ const event = Object.freeze({ previous, current });
105
150
  const listeners = [...this.#generationCommitListeners];
106
151
  queueMicrotask(() => {
107
152
  for (const listener of listeners) {
108
153
  try {
109
- listener(event);
154
+ void Promise.resolve(listener(event)).catch((error) => {
155
+ this.#reportControlError(error);
156
+ });
110
157
  }
111
158
  catch (error) {
112
159
  this.#reportControlError(error);
113
160
  }
114
161
  }
115
162
  });
116
- return snapshot;
117
- }
118
- #openNext(handoff) {
119
- try {
120
- handoff?.openNext();
121
- }
122
- catch (error) {
123
- this.#reportControlError(error);
124
- }
163
+ return current.snapshot;
125
164
  }
126
165
  #reportControlError(error) {
127
166
  try {
@@ -141,14 +180,6 @@ export class RootController {
141
180
  errors.push(error);
142
181
  }
143
182
  }
144
- if (prepared && state.quiesced) {
145
- try {
146
- await prepared.handoff?.resumePrevious();
147
- }
148
- catch (error) {
149
- errors.push(error);
150
- }
151
- }
152
183
  if (prepared) {
153
184
  try {
154
185
  await prepared.dispose();
@@ -157,9 +188,22 @@ export class RootController {
157
188
  errors.push(error);
158
189
  }
159
190
  }
160
- if (errors.length > 1) {
161
- throw new AggregateError(errors, 'Generation transaction and rollback both failed');
191
+ if (errors.length > 1 || transactionError instanceof GenerationCompensationError) {
192
+ const aggregate = new AggregateError(errors, 'Generation transaction and rollback both failed');
193
+ this.#failIntegrity(aggregate);
194
+ throw new RootIntegrityError('Root integrity failed while rolling back a generation transaction', { cause: aggregate });
162
195
  }
163
196
  throw transactionError;
164
197
  }
198
+ #failIntegrity(error) {
199
+ if (this.#state === 'stopped' || this.#state === 'stopping')
200
+ return;
201
+ this.#state = 'failed';
202
+ this.#reportControlError(error);
203
+ }
204
+ #assertPublishable(expected) {
205
+ if (this.#state !== expected || this.#stopRequested) {
206
+ throw new RootIntegrityError(`Root cannot publish a generation after admission entered ${this.#stopRequested ? 'stopping' : this.#state}`);
207
+ }
208
+ }
165
209
  }
package/lib/snapshot.d.ts CHANGED
@@ -22,11 +22,20 @@ export interface RuntimeSnapshot {
22
22
  readonly projections: ReadonlyMap<FeatureId, unknown>;
23
23
  }
24
24
  export type SnapshotState = Omit<RuntimeSnapshot, 'generation'>;
25
- export interface PreparedGeneration {
25
+ interface PreparedGenerationBase {
26
26
  readonly snapshot: SnapshotState;
27
27
  readonly dispose: Dispose;
28
28
  readonly handoff?: GenerationHandoff;
29
29
  }
30
+ export type PreparedGeneration<TState = undefined> = PreparedGenerationBase & ([TState] extends [undefined] ? {
31
+ readonly state?: undefined;
32
+ } : {
33
+ readonly state: TState;
34
+ });
35
+ export interface CommittedGeneration<TState = undefined> {
36
+ readonly snapshot: RuntimeSnapshot;
37
+ readonly state: TState;
38
+ }
30
39
  export declare class GenerationConflictError extends Error {
31
40
  readonly expected: number;
32
41
  readonly actual: number;
@@ -41,13 +50,21 @@ export declare class SnapshotLease {
41
50
  get active(): boolean;
42
51
  release(): void;
43
52
  }
44
- export declare class SnapshotStore {
53
+ /** Read-only operation seam exposed by a Root lifecycle. */
54
+ export interface SnapshotReader {
55
+ acquire(): SnapshotLease;
56
+ owns(lease: SnapshotLease): boolean;
57
+ }
58
+ export declare class SnapshotStore<TState = undefined> implements SnapshotReader {
45
59
  #private;
46
- constructor(initial: SnapshotState);
60
+ private readonly onDisposalError;
61
+ constructor(initial: SnapshotState, onDisposalError?: (error: unknown) => void, ...state: [TState] extends [undefined] ? [initialState?: undefined] : [initialState: TState]);
47
62
  get current(): RuntimeSnapshot;
63
+ /** Complete active record; snapshot and state always belong to one generation. */
64
+ get committed(): CommittedGeneration<TState>;
48
65
  acquire(): SnapshotLease;
49
66
  owns(lease: SnapshotLease): boolean;
50
- commit(expectedGeneration: number, prepared: PreparedGeneration): RuntimeSnapshot;
67
+ commit(expectedGeneration: number, prepared: PreparedGeneration<TState>): CommittedGeneration<TState>;
51
68
  close(): Promise<void>;
52
69
  }
53
70
  export declare function createSnapshotView(generation: number, state: SnapshotState): RuntimeSnapshot;
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,30 +31,41 @@ 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, ...state) {
40
+ this.onDisposalError = onDisposalError;
41
+ const initialState = state[0];
42
+ const snapshot = createSnapshotView(0, initial);
43
+ const committed = Object.freeze({ snapshot, state: initialState });
44
+ const admissions = collectSnapshotAdmissions(snapshot);
45
+ const record = {
46
+ snapshot,
47
+ state: initialState,
48
+ committed,
40
49
  dispose: () => undefined,
50
+ admissions,
41
51
  leases: 0,
42
52
  retired: false,
43
53
  };
54
+ replaceGenerationAdmissions(new Set(), admissions, this, () => this.#retain(record));
55
+ this.#current = record;
44
56
  }
45
57
  get current() {
46
58
  return this.#current.snapshot;
47
59
  }
60
+ /** Complete active record; snapshot and state always belong to one generation. */
61
+ get committed() {
62
+ return this.#current.committed;
63
+ }
48
64
  acquire() {
49
65
  if (this.#closed)
50
66
  throw new Error('SnapshotStore is closed');
51
67
  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);
68
+ const lease = new SnapshotLease(record.snapshot, this.#retain(record), snapshotLeaseAuthority);
57
69
  this.#leases.add(lease);
58
70
  return lease;
59
71
  }
@@ -70,23 +82,33 @@ export class SnapshotStore {
70
82
  throw new GenerationConflictError(expectedGeneration, this.#current.snapshot.generation);
71
83
  }
72
84
  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),
85
+ const snapshot = createSnapshotView(expectedGeneration + 1, prepared.snapshot);
86
+ const state = prepared.state;
87
+ const committed = Object.freeze({ snapshot, state });
88
+ const admissions = collectSnapshotAdmissions(snapshot);
89
+ const next = {
90
+ snapshot,
91
+ state,
92
+ committed,
77
93
  dispose: prepared.dispose,
94
+ admissions,
78
95
  leases: 0,
79
96
  retired: false,
80
97
  };
98
+ replaceGenerationAdmissions(previous.admissions, admissions, this, () => this.#retain(next));
99
+ // Commit only swaps the active pointer. The previous record may still be
100
+ // serving in-flight work, so disposal is deferred to its final lease.
101
+ this.#current = next;
81
102
  previous.retired = true;
82
103
  this.#retired.add(previous);
83
104
  this.#disposeIfReady(previous);
84
- return this.#current.snapshot;
105
+ return this.#current.committed;
85
106
  }
86
107
  async close() {
87
108
  if (this.#closed)
88
109
  return;
89
110
  this.#closed = true;
111
+ replaceGenerationAdmissions(this.#current.admissions, new Set(), this);
90
112
  this.#current.retired = true;
91
113
  this.#retired.add(this.#current);
92
114
  this.#disposeIfReady(this.#current);
@@ -100,9 +122,25 @@ export class SnapshotStore {
100
122
  return;
101
123
  if (!record.disposing) {
102
124
  record.disposing = Promise.resolve().then(record.dispose);
103
- void record.disposing.then(() => record.drain?.resolve(), (error) => record.drain?.reject(error));
125
+ void record.disposing.then(() => record.drain?.resolve(), (error) => {
126
+ record.drain?.reject(error);
127
+ this.onDisposalError(error);
128
+ });
104
129
  }
105
130
  }
131
+ #retain(record) {
132
+ record.leases += 1;
133
+ let released = false;
134
+ return () => {
135
+ if (released)
136
+ return;
137
+ released = true;
138
+ record.leases -= 1;
139
+ if (record.leases < 0)
140
+ throw new Error('Snapshot lease underflow');
141
+ this.#disposeIfReady(record);
142
+ };
143
+ }
106
144
  #waitForDisposal(record) {
107
145
  if (record.disposing)
108
146
  return record.disposing;
@@ -118,6 +156,12 @@ export class SnapshotStore {
118
156
  return record.drain.promise;
119
157
  }
120
158
  }
159
+ function collectSnapshotAdmissions(snapshot) {
160
+ return collectGenerationAdmissions([
161
+ ...snapshot.projections.values(),
162
+ ...[...snapshot.resources.values()].flatMap((resources) => [...resources.values()]),
163
+ ]);
164
+ }
121
165
  export function createSnapshotView(generation, state) {
122
166
  // Object.freeze(Map) does not disable Map#set. ReadonlyMapView removes the
123
167
  // mutator interface at runtime, including from provider projection hooks.
package/lib/token.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface Token<T> {
6
6
  readonly _type?: () => T;
7
7
  }
8
8
  export declare function createToken<T>(id: string, description?: string): Token<T>;
9
+ export declare function createRootPrivateToken<T>(id: string, description?: string): Token<T>;
9
10
  export declare class Scope {
10
11
  #private;
11
12
  readonly owner: PluginId;
package/lib/token.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { DisposeStack } from './dispose.js';
2
2
  import { tokenId } from './identity.js';
3
+ const rootPrivateTokens = new WeakSet();
3
4
  export function createToken(id, description) {
4
5
  return Object.freeze({ id: tokenId(id), description });
5
6
  }
7
+ export function createRootPrivateToken(id, description) {
8
+ const token = { id: tokenId(id), description };
9
+ rootPrivateTokens.add(token);
10
+ return Object.freeze(token);
11
+ }
6
12
  export class Scope {
7
13
  owner;
8
14
  parent;
@@ -20,20 +26,36 @@ export class Scope {
20
26
  if (this.#bindings.has(token.id)) {
21
27
  throw new Error(`Duplicate resource ${token.id} in ${this.owner}`);
22
28
  }
23
- this.#bindings.set(token.id, Object.freeze({ owner: this.owner, value }));
29
+ this.#bindings.set(token.id, Object.freeze({
30
+ owner: this.owner,
31
+ value,
32
+ rootPrivate: rootPrivateTokens.has(token),
33
+ token,
34
+ }));
24
35
  if (dispose)
25
36
  this.disposers.add(dispose);
26
37
  }
27
38
  use(token) {
28
39
  const binding = this.#bindings.get(token.id);
29
- if (binding)
30
- return binding.value;
31
- if (this.parent)
32
- return this.parent.use(token);
40
+ if (binding) {
41
+ if (!binding.rootPrivate || binding.token === token)
42
+ return binding.value;
43
+ throw new Error(`Missing resource ${token.id} for ${this.owner}`);
44
+ }
45
+ const inherited = this.parent
46
+ ? this.parent.#resolveForDescendant(token.id)
47
+ : undefined;
48
+ if (inherited?.status === 'found')
49
+ return inherited.binding.value;
33
50
  throw new Error(`Missing resource ${token.id} for ${this.owner}`);
34
51
  }
35
52
  has(token) {
36
- return this.#bindings.has(token.id) || Boolean(this.parent?.has(token));
53
+ const binding = this.#bindings.get(token.id);
54
+ if (binding)
55
+ return !binding.rootPrivate || binding.token === token;
56
+ return this.parent
57
+ ? this.parent.#resolveForDescendant(token.id)?.status === 'found'
58
+ : false;
37
59
  }
38
60
  seal() {
39
61
  this.#sealed = true;
@@ -45,8 +67,19 @@ export class Scope {
45
67
  const result = this.parent
46
68
  ? new Map(this.parent.snapshot())
47
69
  : new Map();
48
- for (const [id, binding] of this.#bindings)
49
- result.set(id, binding.value);
70
+ for (const [id, binding] of this.#bindings) {
71
+ if (!binding.rootPrivate)
72
+ result.set(id, binding.value);
73
+ }
50
74
  return result;
51
75
  }
76
+ #resolveForDescendant(id) {
77
+ const binding = this.#bindings.get(id);
78
+ if (binding) {
79
+ return binding.rootPrivate
80
+ ? Object.freeze({ status: 'blocked' })
81
+ : Object.freeze({ status: 'found', binding });
82
+ }
83
+ return this.parent ? this.parent.#resolveForDescendant(id) : undefined;
84
+ }
52
85
  }
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.7",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",