@zhin.js/plugin-runtime 1.1.12 → 1.1.13
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/lib/admission.d.ts +1 -1
- package/lib/admission.js +18 -1
- package/lib/database-host.d.ts +14 -2
- package/lib/database-host.js +68 -11
- package/lib/delivery-target.d.ts +20 -0
- package/lib/delivery-target.js +36 -0
- package/lib/html-renderer.d.ts +2 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/outbound-host.d.ts +35 -2
- package/lib/outbound-host.js +57 -1
- package/lib/owner-operation.d.ts +14 -0
- package/lib/owner-operation.js +80 -0
- package/lib/owner-scoped-resource-host.d.ts +2 -1
- package/lib/owner-scoped-resource-host.js +5 -6
- package/lib/persistent-state.d.ts +45 -0
- package/lib/persistent-state.js +124 -0
- package/lib/plugin-verification.d.ts +18 -0
- package/lib/plugin-verification.js +2 -0
- package/lib/plugin.d.ts +8 -0
- package/lib/plugin.js +5 -0
- package/lib/schedule-host.d.ts +28 -3
- package/lib/schedule-host.js +54 -8
- package/lib/snapshot.d.ts +4 -0
- package/lib/snapshot.js +14 -5
- package/lib/token.d.ts +3 -2
- package/lib/token.js +23 -9
- package/package.json +1 -1
package/lib/admission.d.ts
CHANGED
|
@@ -26,4 +26,4 @@ export interface GenerationAdmissionSource {
|
|
|
26
26
|
}
|
|
27
27
|
export declare function bindGenerationAdmission<T>(value: T, gate: GenerationAdmissionGate): T;
|
|
28
28
|
export declare function collectGenerationAdmissions(values: Iterable<unknown>): ReadonlySet<GenerationAdmissionGate>;
|
|
29
|
-
export declare function replaceGenerationAdmissions(previous: ReadonlySet<GenerationAdmissionGate>, next: ReadonlySet<GenerationAdmissionGate>, owner: object, acquireNext?: () => () => void): void;
|
|
29
|
+
export declare function replaceGenerationAdmissions(previous: ReadonlySet<GenerationAdmissionGate>, next: ReadonlySet<GenerationAdmissionGate>, owner: object, publishNext: () => void, acquireNext?: () => () => void): void;
|
package/lib/admission.js
CHANGED
|
@@ -14,6 +14,8 @@ export class GenerationAdmissionGate {
|
|
|
14
14
|
claim: (gate, owner) => { gate.#owner = owner; },
|
|
15
15
|
activate: (gate, acquire) => gate.#activate(acquire),
|
|
16
16
|
deactivate: (gate) => gate.#deactivate(),
|
|
17
|
+
notifyActivation: (gate) => gate.#notifyActivation(),
|
|
18
|
+
notifyDeactivation: (gate) => gate.#notifyDeactivation(),
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
get active() {
|
|
@@ -72,6 +74,8 @@ export class GenerationAdmissionGate {
|
|
|
72
74
|
}
|
|
73
75
|
#activate(acquire) {
|
|
74
76
|
this.#state = { active: true, acquire };
|
|
77
|
+
}
|
|
78
|
+
#notifyActivation() {
|
|
75
79
|
for (const listener of this.#activationListeners) {
|
|
76
80
|
try {
|
|
77
81
|
listener();
|
|
@@ -85,6 +89,8 @@ export class GenerationAdmissionGate {
|
|
|
85
89
|
}
|
|
86
90
|
#deactivate() {
|
|
87
91
|
this.#state = { active: false };
|
|
92
|
+
}
|
|
93
|
+
#notifyDeactivation() {
|
|
88
94
|
for (const listener of this.#deactivationListeners) {
|
|
89
95
|
try {
|
|
90
96
|
listener();
|
|
@@ -130,7 +136,7 @@ export function collectGenerationAdmissions(values) {
|
|
|
130
136
|
}
|
|
131
137
|
return result;
|
|
132
138
|
}
|
|
133
|
-
export function replaceGenerationAdmissions(previous, next, owner, acquireNext) {
|
|
139
|
+
export function replaceGenerationAdmissions(previous, next, owner, publishNext, acquireNext) {
|
|
134
140
|
// Validate the complete switch before changing ownership or visibility. A
|
|
135
141
|
// rejected cross-Root gate must leave both generations exactly untouched.
|
|
136
142
|
for (const gate of next)
|
|
@@ -138,6 +144,9 @@ export function replaceGenerationAdmissions(previous, next, owner, acquireNext)
|
|
|
138
144
|
for (const gate of previous) {
|
|
139
145
|
admissionLifecycle.assertOwner(gate, owner);
|
|
140
146
|
}
|
|
147
|
+
// This callback is SnapshotStore's infallible pointer assignment. Every
|
|
148
|
+
// potentially rejecting ownership check has completed before publication.
|
|
149
|
+
publishNext();
|
|
141
150
|
for (const gate of next)
|
|
142
151
|
admissionLifecycle.claim(gate, owner);
|
|
143
152
|
for (const gate of previous) {
|
|
@@ -148,6 +157,14 @@ export function replaceGenerationAdmissions(previous, next, owner, acquireNext)
|
|
|
148
157
|
for (const gate of next) {
|
|
149
158
|
admissionLifecycle.activate(gate, acquireNext);
|
|
150
159
|
}
|
|
160
|
+
// Publish the complete gate vector before any callback can enter the gateway
|
|
161
|
+
// or synchronously use another candidate-owned service.
|
|
162
|
+
for (const gate of previous) {
|
|
163
|
+
if (!next.has(gate))
|
|
164
|
+
admissionLifecycle.notifyDeactivation(gate);
|
|
165
|
+
}
|
|
166
|
+
for (const gate of next)
|
|
167
|
+
admissionLifecycle.notifyActivation(gate);
|
|
151
168
|
}
|
|
152
169
|
function isObject(value) {
|
|
153
170
|
return (typeof value === 'object' && value !== null) || typeof value === 'function';
|
package/lib/database-host.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { PluginId } from './identity.js';
|
|
2
2
|
import { OwnerScopedResourceHost } from './owner-scoped-resource-host.js';
|
|
3
|
+
import { scopeSetupFinalizer } from './token.js';
|
|
4
|
+
import { PluginStateStore, type PersistentStatePort } from './persistent-state.js';
|
|
5
|
+
import { generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionGate } from './admission.js';
|
|
3
6
|
export interface DatabaseHostSelection {
|
|
4
7
|
where(query: Record<string, unknown>): DatabaseHostSelection;
|
|
5
8
|
orderBy(field: string, direction?: 'ASC' | 'DESC'): DatabaseHostSelection;
|
|
@@ -71,6 +74,8 @@ export interface DatabaseHostConsole {
|
|
|
71
74
|
* `activateNext` so models are available before commands / cron run.
|
|
72
75
|
*/
|
|
73
76
|
export interface DatabaseHost {
|
|
77
|
+
/** Small-object persistence with database-level compare-and-swap. */
|
|
78
|
+
readonly state?: PersistentStatePort;
|
|
74
79
|
readonly dialect: string;
|
|
75
80
|
readonly started: boolean;
|
|
76
81
|
/** Register a table schema before `start` (column defs match `@zhin.js/database`). */
|
|
@@ -104,11 +109,18 @@ export interface DatabaseHost {
|
|
|
104
109
|
export declare function qualifyPluginResourceName(owner: PluginId, name: string): string;
|
|
105
110
|
/** Owner-bound persistence facade without Console administration or raw DB access. */
|
|
106
111
|
export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
107
|
-
private
|
|
112
|
+
#private;
|
|
108
113
|
readonly models: {
|
|
109
114
|
get(name: string): DatabaseHostModel | undefined;
|
|
110
115
|
};
|
|
111
|
-
constructor(owner: PluginId, host: DatabaseHost);
|
|
116
|
+
constructor(owner: PluginId, host: DatabaseHost, admission?: GenerationAdmissionGate);
|
|
117
|
+
get state(): PluginStateStore;
|
|
118
|
+
/** @internal Bound by generation publication, never a plugin authoring operation. */
|
|
119
|
+
[generationAdmissionBinder](gate: GenerationAdmissionGate): PluginDatabaseHost;
|
|
120
|
+
/** @internal Scope-owned declaration window. */
|
|
121
|
+
[scopeSetupFinalizer](): void;
|
|
122
|
+
/** @internal Admission source collected by the Runtime. */
|
|
123
|
+
get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
|
|
112
124
|
get dialect(): string;
|
|
113
125
|
get started(): boolean;
|
|
114
126
|
define(name: string, definition: Record<string, unknown>): void;
|
package/lib/database-host.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { OwnerScopedResourceHost, qualifyOwnedResourceName, } from './owner-scoped-resource-host.js';
|
|
2
|
-
import { createToken } from './token.js';
|
|
2
|
+
import { createRootPrivateToken, createToken, scopeSetupFinalizer } from './token.js';
|
|
3
|
+
import { PluginStateStore } from './persistent-state.js';
|
|
4
|
+
import { generationAdmissionBinder, generationAdmissionSource } from './admission.js';
|
|
5
|
+
import { OwnerOperation } from './owner-operation.js';
|
|
3
6
|
/**
|
|
4
7
|
* Persistence surface visible to a plugin. Names are logical to the owner;
|
|
5
8
|
* the Runtime maps them to private physical table names before they reach the
|
|
@@ -14,27 +17,81 @@ export function qualifyPluginResourceName(owner, name) {
|
|
|
14
17
|
}
|
|
15
18
|
/** Owner-bound persistence facade without Console administration or raw DB access. */
|
|
16
19
|
export class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
17
|
-
host;
|
|
18
20
|
models;
|
|
19
|
-
|
|
21
|
+
#host;
|
|
22
|
+
#admission;
|
|
23
|
+
#operation;
|
|
24
|
+
constructor(owner, host, admission) {
|
|
20
25
|
super(owner);
|
|
21
|
-
this
|
|
22
|
-
this
|
|
26
|
+
this.#host = host;
|
|
27
|
+
this.#admission = admission;
|
|
28
|
+
this.#operation = new OwnerOperation(admission, 'Database');
|
|
29
|
+
this.models = Object.freeze({ get: (name) => this.#operation.setup(() => {
|
|
30
|
+
const model = this.#host.models.get(this.qualify(name));
|
|
31
|
+
return model ? this.#bindModel(model) : undefined;
|
|
32
|
+
}) });
|
|
33
|
+
}
|
|
34
|
+
get state() {
|
|
35
|
+
if (!this.#host.state)
|
|
36
|
+
throw new Error('Persistent state requires a configured database with atomic state support');
|
|
37
|
+
return new PluginStateStore(super.owner, this.#host.state, undefined, this.#admission);
|
|
38
|
+
}
|
|
39
|
+
/** @internal Bound by generation publication, never a plugin authoring operation. */
|
|
40
|
+
[generationAdmissionBinder](gate) {
|
|
41
|
+
const facade = new PluginDatabaseHost(super.owner, this.#host, gate);
|
|
42
|
+
facade[scopeSetupFinalizer]();
|
|
43
|
+
return facade;
|
|
44
|
+
}
|
|
45
|
+
/** @internal Scope-owned declaration window. */
|
|
46
|
+
[scopeSetupFinalizer]() { this.#operation.endSetup(); }
|
|
47
|
+
/** @internal Admission source collected by the Runtime. */
|
|
48
|
+
get [generationAdmissionSource]() {
|
|
49
|
+
return this.#admission ? [this.#admission] : [];
|
|
23
50
|
}
|
|
24
51
|
get dialect() {
|
|
25
|
-
return this
|
|
52
|
+
return this.#host.dialect;
|
|
26
53
|
}
|
|
27
54
|
get started() {
|
|
28
|
-
return this
|
|
55
|
+
return this.#host.started;
|
|
29
56
|
}
|
|
30
57
|
define(name, definition) {
|
|
31
|
-
this.host.define(this.qualify(name), definition);
|
|
58
|
+
this.#operation.setup(() => this.#host.define(this.qualify(name), definition));
|
|
32
59
|
}
|
|
33
60
|
tables() {
|
|
34
|
-
return Object.freeze(this
|
|
61
|
+
return this.#operation.setup(() => Object.freeze(this.#host.tables().flatMap((name) => {
|
|
35
62
|
const logical = this.unqualify(name);
|
|
36
63
|
return logical === undefined ? [] : [logical];
|
|
37
|
-
}));
|
|
64
|
+
})));
|
|
65
|
+
}
|
|
66
|
+
#bindSelection(selection) {
|
|
67
|
+
const modify = (operation) => this.#operation.setup(() => {
|
|
68
|
+
const next = operation();
|
|
69
|
+
return next === selection ? chain : this.#bindSelection(next);
|
|
70
|
+
});
|
|
71
|
+
const chain = {
|
|
72
|
+
where: (query) => modify(() => selection.where(query)),
|
|
73
|
+
orderBy: (field, direction) => modify(() => selection.orderBy(field, direction)),
|
|
74
|
+
limit: (count) => modify(() => selection.limit(count)),
|
|
75
|
+
then: (fulfilled, rejected) => this.#execute(() => Promise.resolve(selection)).then(fulfilled, rejected),
|
|
76
|
+
};
|
|
77
|
+
return Object.freeze(chain);
|
|
78
|
+
}
|
|
79
|
+
#bindModel(model) {
|
|
80
|
+
const count = model.count?.bind(model);
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
select: (...fields) => this.#operation.setup(() => this.#bindSelection(model.select(...fields))),
|
|
83
|
+
insert: (row) => this.#execute(() => model.insert(row)),
|
|
84
|
+
delete: () => this.#operation.setup(() => Object.freeze({
|
|
85
|
+
where: (query) => this.#execute(() => model.delete().where(query)),
|
|
86
|
+
})),
|
|
87
|
+
update: (patch) => this.#operation.setup(() => Object.freeze({
|
|
88
|
+
where: (query) => this.#execute(() => model.update(patch).where(query)),
|
|
89
|
+
})),
|
|
90
|
+
...(count ? { count: (query) => this.#execute(() => count(query)) } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async #execute(operation) {
|
|
94
|
+
return this.#operation.run(operation);
|
|
38
95
|
}
|
|
39
96
|
}
|
|
40
97
|
/**
|
|
@@ -46,4 +103,4 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
46
103
|
/** @public Plugin-facing database facade bound to the current owner. */
|
|
47
104
|
export const databaseHostToken = createToken('zhin.database.host', 'Plugin Runtime scoped database host');
|
|
48
105
|
/** Root-only process host for CLI composition and Console administration. */
|
|
49
|
-
export const databaseRootHostToken =
|
|
106
|
+
export const databaseRootHostToken = createRootPrivateToken('zhin.database.root-host', 'Plugin Runtime root database host');
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** JSON-safe identity of one exact endpoint and conversation. */
|
|
2
|
+
export type DeliveryTarget = Readonly<{
|
|
3
|
+
readonly endpoint: Readonly<{
|
|
4
|
+
readonly adapter: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
}>;
|
|
7
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly parent?: Readonly<{
|
|
10
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
11
|
+
readonly id: string;
|
|
12
|
+
}>;
|
|
13
|
+
readonly threadId?: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** Copies public message identity only; never retains a Message, client or generation. */
|
|
16
|
+
export declare function createDeliveryTarget(message: {
|
|
17
|
+
readonly conversation: DeliveryTarget;
|
|
18
|
+
}): DeliveryTarget;
|
|
19
|
+
/** Validate persisted/untrusted input, returning only the canonical address fields. */
|
|
20
|
+
export declare function normalizeDeliveryTarget(value: unknown): DeliveryTarget;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Copies public message identity only; never retains a Message, client or generation. */
|
|
2
|
+
export function createDeliveryTarget(message) {
|
|
3
|
+
return normalizeDeliveryTarget(message.conversation);
|
|
4
|
+
}
|
|
5
|
+
/** Validate persisted/untrusted input, returning only the canonical address fields. */
|
|
6
|
+
export function normalizeDeliveryTarget(value) {
|
|
7
|
+
const target = record(value, 'delivery target');
|
|
8
|
+
const endpoint = record(target.endpoint, 'delivery target endpoint');
|
|
9
|
+
const result = {
|
|
10
|
+
endpoint: Object.freeze({ adapter: identifier(endpoint.adapter), id: identifier(endpoint.id) }),
|
|
11
|
+
kind: kind(target.kind),
|
|
12
|
+
id: identifier(target.id),
|
|
13
|
+
...(target.parent === undefined ? {} : { parent: parent(target.parent) }),
|
|
14
|
+
...(target.threadId === undefined ? {} : { threadId: identifier(target.threadId) }),
|
|
15
|
+
};
|
|
16
|
+
return Object.freeze(result);
|
|
17
|
+
}
|
|
18
|
+
function record(value, name) {
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
20
|
+
throw new TypeError(`Invalid ${name}`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function identifier(value) {
|
|
24
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
25
|
+
throw new TypeError('Delivery identity must be a nonempty string');
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function kind(value) {
|
|
29
|
+
if (value !== 'private' && value !== 'group' && value !== 'channel')
|
|
30
|
+
throw new TypeError('Invalid conversation kind');
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function parent(value) {
|
|
34
|
+
const address = record(value, 'conversation parent');
|
|
35
|
+
return Object.freeze({ kind: kind(address.kind), id: identifier(address.id) });
|
|
36
|
+
}
|
package/lib/html-renderer.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export interface HtmlRenderOptions {
|
|
2
|
+
/** Cooperative cancellation; callers also stop awaiting hosts that ignore it. */
|
|
3
|
+
readonly signal?: AbortSignal;
|
|
2
4
|
readonly width?: number;
|
|
3
5
|
/** Static raster output; defaults to PNG. */
|
|
4
6
|
readonly format?: 'png' | 'jpeg' | 'webp';
|
package/lib/index.d.ts
CHANGED
|
@@ -31,5 +31,8 @@ export * from './component-host.js';
|
|
|
31
31
|
export * from './outbound-host.js';
|
|
32
32
|
/** @public 跨通道发送辅助:`createOutboundSender` / `sendTo`(承诺 semver)。 */
|
|
33
33
|
export * from './outbound-sender.js';
|
|
34
|
+
export * from './delivery-target.js';
|
|
35
|
+
export * from './persistent-state.js';
|
|
34
36
|
export * from './inbox.js';
|
|
35
37
|
export * from './system-log.js';
|
|
38
|
+
export * from './plugin-verification.js';
|
package/lib/index.js
CHANGED
|
@@ -31,5 +31,8 @@ export * from './component-host.js';
|
|
|
31
31
|
export * from './outbound-host.js';
|
|
32
32
|
/** @public 跨通道发送辅助:`createOutboundSender` / `sendTo`(承诺 semver)。 */
|
|
33
33
|
export * from './outbound-sender.js';
|
|
34
|
+
export * from './delivery-target.js';
|
|
35
|
+
export * from './persistent-state.js';
|
|
34
36
|
export * from './inbox.js';
|
|
35
37
|
export * from './system-log.js';
|
|
38
|
+
export * from './plugin-verification.js';
|
package/lib/outbound-host.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { DeliveryTarget } from './delivery-target.js';
|
|
2
|
+
import type { PluginId } from './identity.js';
|
|
3
|
+
import { generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionGate } from './admission.js';
|
|
1
4
|
/**
|
|
2
5
|
* 未锚定 endpoint 的会话地址(结构对齐 `@zhin.js/im-contract` 的
|
|
3
6
|
* `Omit<ConversationRef, 'endpoint'>`;plugin-runtime 是零依赖 kernel 层,
|
|
@@ -7,7 +10,7 @@ export interface OutboundConversation {
|
|
|
7
10
|
readonly kind: 'private' | 'group' | 'channel';
|
|
8
11
|
readonly id: string;
|
|
9
12
|
readonly parent?: Readonly<{
|
|
10
|
-
readonly kind: 'group' | 'channel';
|
|
13
|
+
readonly kind: 'private' | 'group' | 'channel';
|
|
11
14
|
readonly id: string;
|
|
12
15
|
}>;
|
|
13
16
|
readonly threadId?: string;
|
|
@@ -69,11 +72,16 @@ export interface OutboundMessage {
|
|
|
69
72
|
readonly id: string;
|
|
70
73
|
}
|
|
71
74
|
export interface OutboundHost {
|
|
75
|
+
/** Sends to persisted exact identities, rejecting stale addresses instead of aliasing another bot. */
|
|
76
|
+
sendTarget?(target: DeliveryTarget, content: unknown): Promise<string | null>;
|
|
72
77
|
/** Runs related operations against one IM Runtime snapshot captured at ingress. */
|
|
73
78
|
runWithView?<T>(operation: () => Promise<T>): Promise<T>;
|
|
74
79
|
/** Exact operations declared by one live Endpoint. */
|
|
75
80
|
capabilities?(input: OutboundEndpointInput): OutboundEndpointCapabilities | undefined;
|
|
76
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Returns the platform message id, or null when delivery completed without
|
|
83
|
+
* an id. Delivery failures reject; best-effort callers handle them explicitly.
|
|
84
|
+
*/
|
|
77
85
|
send(input: OutboundSendInput): Promise<string | null>;
|
|
78
86
|
/** Optional: platform message reactions (icqq group emoji, etc.). */
|
|
79
87
|
addReaction?(input: OutboundReactionInput): Promise<string | null>;
|
|
@@ -85,5 +93,30 @@ export interface OutboundHost {
|
|
|
85
93
|
/** Optional: toggle the platform-native typing indicator. */
|
|
86
94
|
typing?(input: OutboundTypingInput): Promise<void>;
|
|
87
95
|
}
|
|
96
|
+
/** Root-only process port: requester is supplied by the owner-bound facade. */
|
|
97
|
+
export interface OutboundRootHost extends Omit<OutboundHost, 'sendTarget'> {
|
|
98
|
+
sendTarget?(target: DeliveryTarget, content: unknown, requester: PluginId): Promise<string | null>;
|
|
99
|
+
}
|
|
100
|
+
/** Preserve the sending plugin as requester, independent of the destination endpoint owner. */
|
|
101
|
+
export declare class PluginOutboundHost implements OutboundHost {
|
|
102
|
+
#private;
|
|
103
|
+
readonly sendTarget?: OutboundHost['sendTarget'];
|
|
104
|
+
readonly runWithView?: OutboundHost['runWithView'];
|
|
105
|
+
readonly capabilities?: OutboundHost['capabilities'];
|
|
106
|
+
readonly addReaction?: OutboundHost['addReaction'];
|
|
107
|
+
readonly removeReaction?: OutboundHost['removeReaction'];
|
|
108
|
+
readonly recall?: OutboundHost['recall'];
|
|
109
|
+
readonly edit?: OutboundHost['edit'];
|
|
110
|
+
readonly typing?: OutboundHost['typing'];
|
|
111
|
+
constructor(owner: PluginId, host: OutboundRootHost, admission?: GenerationAdmissionGate);
|
|
112
|
+
get owner(): PluginId;
|
|
113
|
+
send(input: OutboundSendInput): Promise<string | null>;
|
|
114
|
+
/** @internal Bound by generation publication. */
|
|
115
|
+
[generationAdmissionBinder](gate: GenerationAdmissionGate): PluginOutboundHost;
|
|
116
|
+
/** @internal Admission source collected by the Runtime. */
|
|
117
|
+
get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
|
|
118
|
+
}
|
|
88
119
|
/** @public Plugin-facing cross-platform outbound messaging Host. */
|
|
89
120
|
export declare const outboundHostToken: import("./token.js").Token<OutboundHost>;
|
|
121
|
+
/** Root-only process outbound port for composition and owner binding. */
|
|
122
|
+
export declare const outboundRootHostToken: import("./token.js").Token<OutboundRootHost>;
|
package/lib/outbound-host.js
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
import { createToken } from './token.js';
|
|
1
|
+
import { createRootPrivateToken, createToken } from './token.js';
|
|
2
|
+
import { generationAdmissionBinder, generationAdmissionSource } from './admission.js';
|
|
3
|
+
import { OwnerOperation } from './owner-operation.js';
|
|
4
|
+
/** Preserve the sending plugin as requester, independent of the destination endpoint owner. */
|
|
5
|
+
export class PluginOutboundHost {
|
|
6
|
+
sendTarget;
|
|
7
|
+
runWithView;
|
|
8
|
+
capabilities;
|
|
9
|
+
addReaction;
|
|
10
|
+
removeReaction;
|
|
11
|
+
recall;
|
|
12
|
+
edit;
|
|
13
|
+
typing;
|
|
14
|
+
#owner;
|
|
15
|
+
#host;
|
|
16
|
+
#admission;
|
|
17
|
+
#operation;
|
|
18
|
+
constructor(owner, host, admission) {
|
|
19
|
+
this.#owner = owner;
|
|
20
|
+
this.#host = host;
|
|
21
|
+
this.#admission = admission;
|
|
22
|
+
this.#operation = new OwnerOperation(admission, 'Outbound');
|
|
23
|
+
const sendTarget = host.sendTarget?.bind(host);
|
|
24
|
+
const runWithView = host.runWithView?.bind(host);
|
|
25
|
+
const capabilities = host.capabilities?.bind(host);
|
|
26
|
+
const addReaction = host.addReaction?.bind(host);
|
|
27
|
+
const removeReaction = host.removeReaction?.bind(host);
|
|
28
|
+
const recall = host.recall?.bind(host);
|
|
29
|
+
const edit = host.edit?.bind(host);
|
|
30
|
+
const typing = host.typing?.bind(host);
|
|
31
|
+
this.sendTarget = sendTarget ? async (target, content) => this.#run(() => sendTarget(target, content, this.#owner)) : undefined;
|
|
32
|
+
this.runWithView = runWithView ? (operation) => this.#run(() => runWithView(operation)) : undefined;
|
|
33
|
+
this.capabilities = capabilities ? (input) => this.#run(() => capabilities(input)) : undefined;
|
|
34
|
+
this.addReaction = addReaction ? (input) => this.#run(() => addReaction(input)) : undefined;
|
|
35
|
+
this.removeReaction = removeReaction ? (input) => this.#run(() => removeReaction(input)) : undefined;
|
|
36
|
+
this.recall = recall ? (input) => this.#run(() => recall(input)) : undefined;
|
|
37
|
+
this.edit = edit ? (input) => this.#run(() => edit(input)) : undefined;
|
|
38
|
+
this.typing = typing ? (input) => this.#run(() => typing(input)) : undefined;
|
|
39
|
+
}
|
|
40
|
+
get owner() { return this.#owner; }
|
|
41
|
+
async send(input) {
|
|
42
|
+
return this.#run(() => this.#host.send(input));
|
|
43
|
+
}
|
|
44
|
+
/** @internal Bound by generation publication. */
|
|
45
|
+
[generationAdmissionBinder](gate) {
|
|
46
|
+
return new PluginOutboundHost(this.#owner, this.#host, gate);
|
|
47
|
+
}
|
|
48
|
+
/** @internal Admission source collected by the Runtime. */
|
|
49
|
+
get [generationAdmissionSource]() {
|
|
50
|
+
return this.#admission ? [this.#admission] : [];
|
|
51
|
+
}
|
|
52
|
+
#run(operation) {
|
|
53
|
+
return this.#operation.run(operation);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
2
56
|
/** @public Plugin-facing cross-platform outbound messaging Host. */
|
|
3
57
|
export const outboundHostToken = createToken('zhin.outbound.host', 'Plugin Runtime outbound push host');
|
|
58
|
+
/** Root-only process outbound port for composition and owner binding. */
|
|
59
|
+
export const outboundRootHostToken = createRootPrivateToken('zhin.outbound.root-host', 'Plugin Runtime root outbound host');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { GenerationAdmissionGate } from './admission.js';
|
|
2
|
+
/** @internal Facade-local leases; detached work loses authority when its frame settles. */
|
|
3
|
+
export declare class OwnerOperation {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(gate: GenerationAdmissionGate | undefined, label: string);
|
|
6
|
+
/** Schema/job declarations are permitted in setup before the first publication. */
|
|
7
|
+
setup<T>(operation: () => T): T;
|
|
8
|
+
endSetup(reason?: 'seal' | 'dispose'): void;
|
|
9
|
+
/** Runtime-only staging after setup seal, never on a published or failed owner. */
|
|
10
|
+
stage<T>(operation: () => T): T;
|
|
11
|
+
/** A projection may derive a new candidate from a live owner, never a retired one. */
|
|
12
|
+
prepareProjection<T>(operation: () => T): T;
|
|
13
|
+
run<T>(operation: () => T): T;
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
/** @internal Facade-local leases; detached work loses authority when its frame settles. */
|
|
3
|
+
export class OwnerOperation {
|
|
4
|
+
#context = new AsyncLocalStorage();
|
|
5
|
+
#gate;
|
|
6
|
+
#label;
|
|
7
|
+
#published = false;
|
|
8
|
+
#setupOpen = true;
|
|
9
|
+
#disposed = false;
|
|
10
|
+
constructor(gate, label) {
|
|
11
|
+
this.#gate = gate;
|
|
12
|
+
this.#label = label;
|
|
13
|
+
gate?.onActivate(() => { this.#published = true; });
|
|
14
|
+
}
|
|
15
|
+
/** Schema/job declarations are permitted in setup before the first publication. */
|
|
16
|
+
setup(operation) {
|
|
17
|
+
if (!this.#gate || this.#setupOpen && !this.#published || this.#context.getStore()?.active)
|
|
18
|
+
return operation();
|
|
19
|
+
const release = this.#gate.acquire();
|
|
20
|
+
if (!release)
|
|
21
|
+
throw new Error(`${this.#label} operation belongs to an inactive generation`);
|
|
22
|
+
// A query builder may be thenable. Never assimilate it just to check access.
|
|
23
|
+
try {
|
|
24
|
+
return operation();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
release();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
endSetup(reason = 'seal') {
|
|
31
|
+
this.#setupOpen = false;
|
|
32
|
+
if (reason === 'dispose')
|
|
33
|
+
this.#disposed = true;
|
|
34
|
+
}
|
|
35
|
+
/** Runtime-only staging after setup seal, never on a published or failed owner. */
|
|
36
|
+
stage(operation) {
|
|
37
|
+
if (this.#published || this.#disposed)
|
|
38
|
+
throw new Error(`${this.#label} staging belongs to an inactive generation`);
|
|
39
|
+
return operation();
|
|
40
|
+
}
|
|
41
|
+
/** A projection may derive a new candidate from a live owner, never a retired one. */
|
|
42
|
+
prepareProjection(operation) {
|
|
43
|
+
if (this.#disposed || this.#published && !this.#gate?.active)
|
|
44
|
+
throw new Error(`${this.#label} preparation belongs to an inactive generation`);
|
|
45
|
+
return operation();
|
|
46
|
+
}
|
|
47
|
+
run(operation) {
|
|
48
|
+
if (!this.#gate)
|
|
49
|
+
return operation();
|
|
50
|
+
const parent = this.#context.getStore();
|
|
51
|
+
let lease;
|
|
52
|
+
if (parent?.active)
|
|
53
|
+
lease = parent.lease;
|
|
54
|
+
else {
|
|
55
|
+
const release = this.#gate.acquire();
|
|
56
|
+
if (!release)
|
|
57
|
+
throw new Error(`${this.#label} operation belongs to an inactive generation`);
|
|
58
|
+
lease = { pending: 0, release };
|
|
59
|
+
}
|
|
60
|
+
lease.pending++;
|
|
61
|
+
const context = { active: true, lease };
|
|
62
|
+
const finish = () => {
|
|
63
|
+
context.active = false;
|
|
64
|
+
if (--lease.pending === 0)
|
|
65
|
+
lease.release();
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const result = this.#context.run(context, operation);
|
|
69
|
+
if (result && typeof result.then === 'function') {
|
|
70
|
+
return Promise.resolve(result).finally(finish);
|
|
71
|
+
}
|
|
72
|
+
finish();
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
finish();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type PluginId } from './identity.js';
|
|
2
2
|
/** Shared owner identity and physical-name invariant for process-wide resources. */
|
|
3
3
|
export declare abstract class OwnerScopedResourceHost {
|
|
4
|
-
|
|
4
|
+
#private;
|
|
5
5
|
constructor(owner: PluginId);
|
|
6
|
+
get owner(): PluginId;
|
|
6
7
|
protected qualify(name: string): string;
|
|
7
8
|
protected unqualify(name: string): string | undefined;
|
|
8
9
|
}
|
|
@@ -3,16 +3,15 @@ const resourcePrefix = '__zhin_plugin__';
|
|
|
3
3
|
const resourceSeparator = '__';
|
|
4
4
|
/** Shared owner identity and physical-name invariant for process-wide resources. */
|
|
5
5
|
export class OwnerScopedResourceHost {
|
|
6
|
-
owner;
|
|
7
|
-
constructor(owner) {
|
|
8
|
-
|
|
9
|
-
}
|
|
6
|
+
#owner;
|
|
7
|
+
constructor(owner) { this.#owner = owner; }
|
|
8
|
+
get owner() { return this.#owner; }
|
|
10
9
|
qualify(name) {
|
|
11
10
|
assertLogicalName(name);
|
|
12
|
-
return qualifyOwnedResourceName(this
|
|
11
|
+
return qualifyOwnedResourceName(this.#owner, name);
|
|
13
12
|
}
|
|
14
13
|
unqualify(name) {
|
|
15
|
-
const prefix = `${resourcePrefix}${pluginOwnerResourceKey(this
|
|
14
|
+
const prefix = `${resourcePrefix}${pluginOwnerResourceKey(this.#owner)}${resourceSeparator}`;
|
|
16
15
|
return name.startsWith(prefix) ? name.slice(prefix.length) : undefined;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type DeliveryTarget } from './delivery-target.js';
|
|
2
|
+
import type { PluginId } from './identity.js';
|
|
3
|
+
import type { GenerationAdmissionGate } from './admission.js';
|
|
4
|
+
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
|
|
5
|
+
readonly [key: string]: JsonValue;
|
|
6
|
+
};
|
|
7
|
+
export type StateScope = Readonly<{
|
|
8
|
+
kind: 'plugin';
|
|
9
|
+
}> | Readonly<{
|
|
10
|
+
kind: 'user';
|
|
11
|
+
endpoint: DeliveryTarget['endpoint'];
|
|
12
|
+
senderId: string;
|
|
13
|
+
}> | Readonly<{
|
|
14
|
+
kind: 'conversation';
|
|
15
|
+
target: DeliveryTarget;
|
|
16
|
+
}>;
|
|
17
|
+
/** Opaque database revision, including expired rows so updates cannot resurrect stale values. */
|
|
18
|
+
export interface StateRecord {
|
|
19
|
+
readonly revision: string;
|
|
20
|
+
readonly value: JsonValue;
|
|
21
|
+
readonly expiresAt: number | null;
|
|
22
|
+
}
|
|
23
|
+
/** Process-owned port backed by the existing database, never an in-memory persistence fallback. */
|
|
24
|
+
export interface PersistentStatePort {
|
|
25
|
+
read(owner: PluginId, scope: StateScope, key: string): Promise<StateRecord | undefined>;
|
|
26
|
+
compareExchange(owner: PluginId, scope: StateScope, key: string, revision: string | undefined, value: JsonValue | undefined, expiresAt: number | null): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
export interface StateWriteOptions {
|
|
29
|
+
/** Milliseconds from this write attempt; omission stores without expiry. */
|
|
30
|
+
readonly ttlMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Owner-bound small-object state. Updaters must be pure: contention can replay them. */
|
|
33
|
+
export declare class PluginStateStore {
|
|
34
|
+
#private;
|
|
35
|
+
constructor(owner: PluginId, port: PersistentStatePort, scope?: StateScope, admission?: GenerationAdmissionGate);
|
|
36
|
+
get owner(): PluginId;
|
|
37
|
+
forUser(endpoint: DeliveryTarget['endpoint'], senderId: string): PluginStateStore;
|
|
38
|
+
forConversation(target: DeliveryTarget): PluginStateStore;
|
|
39
|
+
get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined>;
|
|
40
|
+
set<T extends JsonValue>(key: string, value: T, options?: StateWriteOptions): Promise<void>;
|
|
41
|
+
delete(key: string): Promise<void>;
|
|
42
|
+
update<T extends JsonValue = JsonValue>(key: string, updater: (current: T | undefined) => T | undefined, options?: StateWriteOptions): Promise<T | undefined>;
|
|
43
|
+
}
|
|
44
|
+
/** Reject lossy JSON conversions instead of silently losing a Date, NaN, undefined or callback. */
|
|
45
|
+
export declare function copyJson(value: JsonValue): JsonValue;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { normalizeDeliveryTarget } from './delivery-target.js';
|
|
2
|
+
/** Owner-bound small-object state. Updaters must be pure: contention can replay them. */
|
|
3
|
+
export class PluginStateStore {
|
|
4
|
+
#owner;
|
|
5
|
+
#port;
|
|
6
|
+
#scope;
|
|
7
|
+
#admission;
|
|
8
|
+
constructor(owner, port, scope = Object.freeze({ kind: 'plugin' }), admission) {
|
|
9
|
+
this.#owner = owner;
|
|
10
|
+
this.#port = port;
|
|
11
|
+
this.#scope = scope;
|
|
12
|
+
this.#admission = admission;
|
|
13
|
+
}
|
|
14
|
+
get owner() { return this.#owner; }
|
|
15
|
+
forUser(endpoint, senderId) {
|
|
16
|
+
if (!senderId)
|
|
17
|
+
throw new TypeError('State senderId must be nonempty');
|
|
18
|
+
const canonical = normalizeDeliveryTarget({ endpoint, kind: 'private', id: senderId });
|
|
19
|
+
return new PluginStateStore(this.#owner, this.#port, Object.freeze({ kind: 'user', endpoint: canonical.endpoint, senderId }), this.#admission);
|
|
20
|
+
}
|
|
21
|
+
forConversation(target) {
|
|
22
|
+
return new PluginStateStore(this.#owner, this.#port, Object.freeze({ kind: 'conversation', target: normalizeDeliveryTarget(target) }), this.#admission);
|
|
23
|
+
}
|
|
24
|
+
async get(key) {
|
|
25
|
+
return this.#withAdmission(async () => {
|
|
26
|
+
assertKey(key);
|
|
27
|
+
const record = await this.#port.read(this.#owner, this.#scope, key);
|
|
28
|
+
return record && (record.expiresAt === null || record.expiresAt > Date.now())
|
|
29
|
+
? copyJson(record.value) : undefined;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async set(key, value, options) {
|
|
33
|
+
const json = copyJson(value);
|
|
34
|
+
await this.update(key, () => json, options);
|
|
35
|
+
}
|
|
36
|
+
async delete(key) {
|
|
37
|
+
await this.update(key, () => undefined);
|
|
38
|
+
}
|
|
39
|
+
async update(key, updater, options) {
|
|
40
|
+
return this.#withAdmission(async () => {
|
|
41
|
+
assertKey(key);
|
|
42
|
+
const ttl = options?.ttlMs;
|
|
43
|
+
if (ttl !== undefined && (!Number.isSafeInteger(ttl) || ttl <= 0))
|
|
44
|
+
throw new TypeError('ttlMs must be a positive integer');
|
|
45
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
46
|
+
const record = await this.#port.read(this.#owner, this.#scope, key);
|
|
47
|
+
const live = record && (record.expiresAt === null || record.expiresAt > Date.now());
|
|
48
|
+
const next = updater(live ? copyJson(record.value) : undefined);
|
|
49
|
+
const value = next === undefined ? undefined : copyJson(next);
|
|
50
|
+
const expiresAt = ttl === undefined ? null : Date.now() + ttl;
|
|
51
|
+
if (expiresAt !== null && !Number.isSafeInteger(expiresAt))
|
|
52
|
+
throw new TypeError('ttlMs exceeds supported time range');
|
|
53
|
+
if (await this.#port.compareExchange(this.#owner, this.#scope, key, record?.revision, value, expiresAt)) {
|
|
54
|
+
return value === undefined ? undefined : copyJson(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error('Persistent state update contention exceeded 64 retries; retry the operation');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async #withAdmission(operation) {
|
|
61
|
+
if (!this.#admission)
|
|
62
|
+
return operation();
|
|
63
|
+
const release = this.#admission.acquire();
|
|
64
|
+
if (!release)
|
|
65
|
+
throw new Error('Persistent state operation belongs to an inactive generation');
|
|
66
|
+
try {
|
|
67
|
+
return await operation();
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
release();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function assertKey(key) {
|
|
75
|
+
if (!key || key.length > 256)
|
|
76
|
+
throw new TypeError('State key must contain 1–256 characters');
|
|
77
|
+
}
|
|
78
|
+
/** Reject lossy JSON conversions instead of silently losing a Date, NaN, undefined or callback. */
|
|
79
|
+
export function copyJson(value) {
|
|
80
|
+
const ancestors = new Set();
|
|
81
|
+
const visit = (item) => {
|
|
82
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean')
|
|
83
|
+
return;
|
|
84
|
+
if (typeof item === 'number' && Number.isFinite(item) && !Object.is(item, -0))
|
|
85
|
+
return;
|
|
86
|
+
if (!item || typeof item !== 'object')
|
|
87
|
+
throw new TypeError('Persistent state must be JSON-safe');
|
|
88
|
+
if (ancestors.has(item))
|
|
89
|
+
throw new TypeError('Persistent state must not contain cycles');
|
|
90
|
+
if (Array.isArray(item) ? Object.getPrototypeOf(item) !== Array.prototype
|
|
91
|
+
: Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) {
|
|
92
|
+
throw new TypeError('Persistent state objects must be plain JSON objects');
|
|
93
|
+
}
|
|
94
|
+
if (Object.getOwnPropertySymbols(item).length)
|
|
95
|
+
throw new TypeError('Persistent state must not contain symbol properties');
|
|
96
|
+
if ('toJSON' in item && !Object.hasOwn(item, 'toJSON'))
|
|
97
|
+
throw new TypeError('Persistent state must not inherit toJSON');
|
|
98
|
+
if (Array.isArray(item)) {
|
|
99
|
+
for (let index = 0; index < item.length; index++) {
|
|
100
|
+
if (!Object.hasOwn(item, index))
|
|
101
|
+
throw new TypeError('Persistent state arrays must not contain holes');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
ancestors.add(item);
|
|
105
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
|
|
106
|
+
if (Array.isArray(item) && key === 'length')
|
|
107
|
+
continue;
|
|
108
|
+
if (!descriptor.enumerable || !('value' in descriptor))
|
|
109
|
+
throw new TypeError('Persistent state must contain ordinary enumerable JSON properties');
|
|
110
|
+
if (Array.isArray(item) && (!/^\d+$/u.test(key) || String(Number(key)) !== key || Number(key) >= item.length)) {
|
|
111
|
+
throw new TypeError('Persistent state arrays must contain only indexed JSON values');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const child of Array.isArray(item) ? item : Object.values(item))
|
|
115
|
+
visit(child);
|
|
116
|
+
ancestors.delete(item);
|
|
117
|
+
};
|
|
118
|
+
visit(value);
|
|
119
|
+
const serialized = JSON.stringify(value);
|
|
120
|
+
// MySQL TEXT has a 65535-byte limit. Keep the same contract on every supported dialect.
|
|
121
|
+
if (new TextEncoder().encode(serialized).byteLength >= 64 * 1024)
|
|
122
|
+
throw new TypeError('Persistent state value must be below 64 KiB');
|
|
123
|
+
return JSON.parse(serialized);
|
|
124
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A deliberate business check. Effects must describe every write or external action. */
|
|
2
|
+
export interface PluginVerificationCheck {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly effects: readonly string[];
|
|
6
|
+
readonly scope: 'local' | 'platform';
|
|
7
|
+
}
|
|
8
|
+
export interface PluginVerificationOutcome {
|
|
9
|
+
readonly status: 'passed' | 'failed';
|
|
10
|
+
readonly summary: string;
|
|
11
|
+
readonly evidence: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/** Plugin-owned optional verification, distinct from generation publication/readiness. */
|
|
14
|
+
export interface PluginVerificationHost {
|
|
15
|
+
checks(): readonly PluginVerificationCheck[];
|
|
16
|
+
run(checkId: string): Promise<PluginVerificationOutcome>;
|
|
17
|
+
}
|
|
18
|
+
export declare const pluginVerificationToken: import("./token.js").Token<PluginVerificationHost>;
|
package/lib/plugin.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface PluginMetadata {
|
|
|
14
14
|
export interface ConfigView<T> {
|
|
15
15
|
get(): Readonly<T>;
|
|
16
16
|
}
|
|
17
|
+
/** Structural schema contract; the Runtime owns validation and defaults. */
|
|
18
|
+
export interface PluginConfigSchema<TConfig> {
|
|
19
|
+
readonly output?: TConfig;
|
|
20
|
+
toJsonSchema(): Readonly<Record<string, unknown>>;
|
|
21
|
+
}
|
|
17
22
|
export interface PluginInstanceView {
|
|
18
23
|
readonly id: PluginId;
|
|
19
24
|
readonly instanceKey: string;
|
|
@@ -37,6 +42,7 @@ export interface PluginSetupContext<TConfig = unknown> {
|
|
|
37
42
|
}
|
|
38
43
|
export interface PluginDefinition<TConfig = unknown> {
|
|
39
44
|
readonly name: string;
|
|
45
|
+
readonly config?: PluginConfigSchema<TConfig>;
|
|
40
46
|
readonly metadata?: PluginMetadata;
|
|
41
47
|
readonly requires?: readonly Token<unknown>[];
|
|
42
48
|
setup?(context: PluginSetupContext<TConfig>): void | Dispose | Promise<void | Dispose>;
|
|
@@ -60,6 +66,8 @@ export interface PluginDefinition<TConfig = unknown> {
|
|
|
60
66
|
* one default export.
|
|
61
67
|
*/
|
|
62
68
|
export declare function definePlugin<TConfig = unknown>(definition: PluginDefinition<TConfig>): Readonly<PluginDefinition<TConfig>>;
|
|
69
|
+
export { createDeliveryTarget, type DeliveryTarget } from './delivery-target.js';
|
|
70
|
+
export { PluginStateStore, type JsonValue, type StateScope, type StateRecord, type PersistentStatePort, type StateWriteOptions } from './persistent-state.js';
|
|
63
71
|
export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, PluginDatabaseHost, } from './database-host.js';
|
|
64
72
|
export { outboundHostToken, type OutboundConversation, type OutboundEditInput, type OutboundEndpointCapabilities, type OutboundEndpointInput, type OutboundEndpointOperation, type OutboundHost, type OutboundMessage, type OutboundReactionInput, type OutboundRecallInput, type OutboundRemoveReactionInput, type OutboundSendInput, type OutboundTypingInput, } from './outbound-host.js';
|
|
65
73
|
export { scheduleHostToken, PluginScheduleHost, type ScheduleJobRegistration, } from './schedule-host.js';
|
package/lib/plugin.js
CHANGED
|
@@ -21,6 +21,9 @@ export function definePlugin(definition) {
|
|
|
21
21
|
if (!pluginNamePattern.test(definition.name)) {
|
|
22
22
|
throw new TypeError(`Invalid plugin name: ${definition.name}`);
|
|
23
23
|
}
|
|
24
|
+
if (definition.config !== undefined && typeof definition.config.toJsonSchema !== 'function') {
|
|
25
|
+
throw new TypeError('Plugin config must declare a schema with toJsonSchema()');
|
|
26
|
+
}
|
|
24
27
|
return Object.freeze({
|
|
25
28
|
...definition,
|
|
26
29
|
metadata: definition.metadata ? Object.freeze({ ...definition.metadata }) : undefined,
|
|
@@ -28,6 +31,8 @@ export function definePlugin(definition) {
|
|
|
28
31
|
});
|
|
29
32
|
}
|
|
30
33
|
// The application facade exposes these Host contracts beside definePlugin.
|
|
34
|
+
export { createDeliveryTarget } from './delivery-target.js';
|
|
35
|
+
export { PluginStateStore } from './persistent-state.js';
|
|
31
36
|
export { databaseHostToken, PluginDatabaseHost, } from './database-host.js';
|
|
32
37
|
export { outboundHostToken, } from './outbound-host.js';
|
|
33
38
|
export { scheduleHostToken, PluginScheduleHost, } from './schedule-host.js';
|
package/lib/schedule-host.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PluginId } from './identity.js';
|
|
2
2
|
import { OwnerScopedResourceHost } from './owner-scoped-resource-host.js';
|
|
3
|
+
import { scopeSetupFinalizer } from './token.js';
|
|
4
|
+
import { generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionGate } from './admission.js';
|
|
3
5
|
/**
|
|
4
6
|
* Thin Host Resource for Plugin Runtime cron jobs.
|
|
5
7
|
* Implementations typically wrap `@zhin.js/schedule` CalendarScheduler (solar cron).
|
|
@@ -11,10 +13,16 @@ export interface ScheduleJobRegistration {
|
|
|
11
13
|
/** 6-field solar cron: `秒 分 时 日 月 周` */
|
|
12
14
|
readonly cron: string;
|
|
13
15
|
readonly description?: string;
|
|
16
|
+
/** @internal Process Host visibility follows atomic generation publication. */
|
|
17
|
+
readonly isActive?: () => boolean;
|
|
14
18
|
execute(): void | Promise<void>;
|
|
15
19
|
}
|
|
16
20
|
export interface ScheduleHost {
|
|
17
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Register a solar cron job; its disposer cancels only this registration.
|
|
23
|
+
* Overlapping generations may share a logical id. Preserve both until their
|
|
24
|
+
* disposers run, and use isActive to select the published registration.
|
|
25
|
+
*/
|
|
18
26
|
register(job: ScheduleJobRegistration): () => void;
|
|
19
27
|
list(): readonly {
|
|
20
28
|
readonly id: string;
|
|
@@ -25,17 +33,34 @@ export interface ScheduleHost {
|
|
|
25
33
|
}
|
|
26
34
|
/** Scoped scheduling surface exposed to a Plugin setup function. */
|
|
27
35
|
export declare function qualifyPluginScheduleId(owner: PluginId, id: string): string;
|
|
36
|
+
declare const stageRegistration: unique symbol;
|
|
37
|
+
declare const prepareProjection: unique symbol;
|
|
38
|
+
/** @internal Each Schedule projection owns its own candidate registration gate. */
|
|
39
|
+
export declare function createPluginScheduleCandidate(host: PluginScheduleHost, gate: GenerationAdmissionGate): PluginScheduleHost;
|
|
40
|
+
/** @internal Schedule Feature stages candidate jobs before atomic publication. */
|
|
41
|
+
export declare function stagePluginScheduleRegistration(host: PluginScheduleHost, job: ScheduleJobRegistration): () => void;
|
|
28
42
|
export declare class PluginScheduleHost extends OwnerScopedResourceHost {
|
|
29
|
-
private
|
|
30
|
-
constructor(owner: PluginId, host: ScheduleHost);
|
|
43
|
+
#private;
|
|
44
|
+
constructor(owner: PluginId, host: ScheduleHost, admission?: GenerationAdmissionGate);
|
|
31
45
|
register(job: ScheduleJobRegistration): () => void;
|
|
46
|
+
/** @internal Runtime-only candidate preparation; the key is private to this module. */
|
|
47
|
+
[stageRegistration](job: ScheduleJobRegistration): () => void;
|
|
48
|
+
/** @internal Derive a fresh candidate without reopening this owner facade. */
|
|
49
|
+
[prepareProjection](gate: GenerationAdmissionGate): PluginScheduleHost;
|
|
32
50
|
list(): readonly {
|
|
33
51
|
readonly id: string;
|
|
34
52
|
readonly cron: string;
|
|
35
53
|
readonly description?: string;
|
|
36
54
|
}[];
|
|
55
|
+
/** @internal Bound by generation publication. */
|
|
56
|
+
[generationAdmissionBinder](gate: GenerationAdmissionGate): PluginScheduleHost;
|
|
57
|
+
/** @internal Scope-owned declaration window. */
|
|
58
|
+
[scopeSetupFinalizer](reason?: 'seal' | 'dispose'): void;
|
|
59
|
+
/** @internal Admission source collected by the Runtime. */
|
|
60
|
+
get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
|
|
37
61
|
}
|
|
38
62
|
/** @public Plugin-facing token; Runtime injects a facade bound to the current owner. */
|
|
39
63
|
export declare const scheduleHostToken: import("./token.js").Token<PluginScheduleHost>;
|
|
40
64
|
/** Root-only process scheduler for CLI composition and Console administration. */
|
|
41
65
|
export declare const scheduleRootHostToken: import("./token.js").Token<ScheduleHost>;
|
|
66
|
+
export {};
|
package/lib/schedule-host.js
CHANGED
|
@@ -1,20 +1,54 @@
|
|
|
1
1
|
import { OwnerScopedResourceHost, qualifyOwnedResourceName, } from './owner-scoped-resource-host.js';
|
|
2
|
-
import { createToken } from './token.js';
|
|
2
|
+
import { createToken, createRootPrivateToken, scopeSetupFinalizer } from './token.js';
|
|
3
|
+
import { generationAdmissionBinder, generationAdmissionSource } from './admission.js';
|
|
4
|
+
import { OwnerOperation } from './owner-operation.js';
|
|
3
5
|
/** Scoped scheduling surface exposed to a Plugin setup function. */
|
|
4
6
|
export function qualifyPluginScheduleId(owner, id) {
|
|
5
7
|
return qualifyOwnedResourceName(owner, id);
|
|
6
8
|
}
|
|
9
|
+
const stageRegistration = Symbol('StageScheduleRegistration');
|
|
10
|
+
const prepareProjection = Symbol('PrepareScheduleProjection');
|
|
11
|
+
/** @internal Each Schedule projection owns its own candidate registration gate. */
|
|
12
|
+
export function createPluginScheduleCandidate(host, gate) {
|
|
13
|
+
return host[prepareProjection](gate);
|
|
14
|
+
}
|
|
15
|
+
/** @internal Schedule Feature stages candidate jobs before atomic publication. */
|
|
16
|
+
export function stagePluginScheduleRegistration(host, job) {
|
|
17
|
+
return host[stageRegistration](job);
|
|
18
|
+
}
|
|
7
19
|
export class PluginScheduleHost extends OwnerScopedResourceHost {
|
|
8
|
-
host;
|
|
9
|
-
|
|
20
|
+
#host;
|
|
21
|
+
#admission;
|
|
22
|
+
#operation;
|
|
23
|
+
constructor(owner, host, admission) {
|
|
10
24
|
super(owner);
|
|
11
|
-
this
|
|
25
|
+
this.#host = host;
|
|
26
|
+
this.#admission = admission;
|
|
27
|
+
this.#operation = new OwnerOperation(admission, 'Schedule');
|
|
12
28
|
}
|
|
13
29
|
register(job) {
|
|
14
|
-
return this.
|
|
30
|
+
return this.#operation.setup(() => this.#register(job));
|
|
31
|
+
}
|
|
32
|
+
/** @internal Runtime-only candidate preparation; the key is private to this module. */
|
|
33
|
+
[stageRegistration](job) {
|
|
34
|
+
return this.#operation.stage(() => this.#register(job));
|
|
35
|
+
}
|
|
36
|
+
/** @internal Derive a fresh candidate without reopening this owner facade. */
|
|
37
|
+
[prepareProjection](gate) {
|
|
38
|
+
return this.#operation.prepareProjection(() => {
|
|
39
|
+
const candidate = new PluginScheduleHost(super.owner, this.#host, gate);
|
|
40
|
+
candidate[scopeSetupFinalizer]('seal');
|
|
41
|
+
return candidate;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
#register(job) {
|
|
45
|
+
return this.#host.register({ ...job, id: this.qualify(job.id), owner: super.owner,
|
|
46
|
+
isActive: () => this.#admission?.active ?? true,
|
|
47
|
+
execute: () => this.#operation.run(() => job.execute()),
|
|
48
|
+
});
|
|
15
49
|
}
|
|
16
50
|
list() {
|
|
17
|
-
return Object.freeze(this
|
|
51
|
+
return this.#operation.setup(() => Object.freeze(this.#host.list().flatMap((job) => {
|
|
18
52
|
const id = this.unqualify(job.id);
|
|
19
53
|
if (id === undefined)
|
|
20
54
|
return [];
|
|
@@ -23,10 +57,22 @@ export class PluginScheduleHost extends OwnerScopedResourceHost {
|
|
|
23
57
|
cron: job.cron,
|
|
24
58
|
...(job.description === undefined ? {} : { description: job.description }),
|
|
25
59
|
})];
|
|
26
|
-
}));
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
/** @internal Bound by generation publication. */
|
|
63
|
+
[generationAdmissionBinder](gate) {
|
|
64
|
+
const facade = new PluginScheduleHost(super.owner, this.#host, gate);
|
|
65
|
+
facade[scopeSetupFinalizer]('dispose');
|
|
66
|
+
return facade;
|
|
67
|
+
}
|
|
68
|
+
/** @internal Scope-owned declaration window. */
|
|
69
|
+
[scopeSetupFinalizer](reason = 'seal') { this.#operation.endSetup(reason); }
|
|
70
|
+
/** @internal Admission source collected by the Runtime. */
|
|
71
|
+
get [generationAdmissionSource]() {
|
|
72
|
+
return this.#admission ? [this.#admission] : [];
|
|
27
73
|
}
|
|
28
74
|
}
|
|
29
75
|
/** @public Plugin-facing token; Runtime injects a facade bound to the current owner. */
|
|
30
76
|
export const scheduleHostToken = createToken('zhin.schedule.host', 'Plugin Runtime scoped solar cron host');
|
|
31
77
|
/** Root-only process scheduler for CLI composition and Console administration. */
|
|
32
|
-
export const scheduleRootHostToken =
|
|
78
|
+
export const scheduleRootHostToken = createRootPrivateToken('zhin.schedule.root-host', 'Plugin Runtime root solar cron host');
|
package/lib/snapshot.d.ts
CHANGED
|
@@ -7,10 +7,14 @@ export interface PluginNodeSnapshot {
|
|
|
7
7
|
readonly id: PluginId;
|
|
8
8
|
readonly instanceKey: string;
|
|
9
9
|
readonly packageName: string;
|
|
10
|
+
/** Package version read when this owner was assembled, never from later disk contents. */
|
|
11
|
+
readonly packageVersion?: string;
|
|
10
12
|
readonly packageRoot: string;
|
|
11
13
|
readonly parent?: PluginId;
|
|
12
14
|
readonly children: readonly PluginId[];
|
|
13
15
|
readonly metadata?: PluginMetadata;
|
|
16
|
+
/** Immutable schema declaration belonging to this committed generation. */
|
|
17
|
+
readonly configSchema?: Readonly<Record<string, unknown>>;
|
|
14
18
|
}
|
|
15
19
|
export interface RuntimeSnapshot {
|
|
16
20
|
readonly generation: number;
|
package/lib/snapshot.js
CHANGED
|
@@ -51,8 +51,8 @@ export class SnapshotStore {
|
|
|
51
51
|
leases: 0,
|
|
52
52
|
retired: false,
|
|
53
53
|
};
|
|
54
|
-
replaceGenerationAdmissions(new Set(), admissions, this, () => this.#retain(record));
|
|
55
54
|
this.#current = record;
|
|
55
|
+
replaceGenerationAdmissions(new Set(), admissions, this, () => undefined, () => this.#retain(record));
|
|
56
56
|
}
|
|
57
57
|
get current() {
|
|
58
58
|
return this.#current.snapshot;
|
|
@@ -95,10 +95,9 @@ export class SnapshotStore {
|
|
|
95
95
|
leases: 0,
|
|
96
96
|
retired: false,
|
|
97
97
|
};
|
|
98
|
-
replaceGenerationAdmissions(previous.admissions, admissions, this, () => this.#retain(next));
|
|
98
|
+
replaceGenerationAdmissions(previous.admissions, admissions, this, () => { this.#current = next; }, () => this.#retain(next));
|
|
99
99
|
// Commit only swaps the active pointer. The previous record may still be
|
|
100
100
|
// serving in-flight work, so disposal is deferred to its final lease.
|
|
101
|
-
this.#current = next;
|
|
102
101
|
previous.retired = true;
|
|
103
102
|
this.#retired.add(previous);
|
|
104
103
|
this.#disposeIfReady(previous);
|
|
@@ -108,7 +107,7 @@ export class SnapshotStore {
|
|
|
108
107
|
if (this.#closed)
|
|
109
108
|
return;
|
|
110
109
|
this.#closed = true;
|
|
111
|
-
replaceGenerationAdmissions(this.#current.admissions, new Set(), this);
|
|
110
|
+
replaceGenerationAdmissions(this.#current.admissions, new Set(), this, () => undefined);
|
|
112
111
|
this.#current.retired = true;
|
|
113
112
|
this.#retired.add(this.#current);
|
|
114
113
|
this.#disposeIfReady(this.#current);
|
|
@@ -168,13 +167,23 @@ export function createSnapshotView(generation, state) {
|
|
|
168
167
|
return Object.freeze({
|
|
169
168
|
...state,
|
|
170
169
|
generation,
|
|
171
|
-
tree: readonlyMap(state.tree),
|
|
170
|
+
tree: readonlyMap([...state.tree].map(([owner, node]) => [owner, node.configSchema
|
|
171
|
+
? Object.freeze({ ...node, configSchema: freezeSchema(structuredClone(node.configSchema)) })
|
|
172
|
+
: node])),
|
|
172
173
|
config: readonlyMap(state.config),
|
|
173
174
|
resources: readonlyMap([...state.resources].map(([owner, resources]) => [owner, readonlyMap(resources)])),
|
|
174
175
|
capabilities: readonlyMap(state.capabilities),
|
|
175
176
|
projections: readonlyMap(state.projections),
|
|
176
177
|
});
|
|
177
178
|
}
|
|
179
|
+
function freezeSchema(value) {
|
|
180
|
+
if (value && typeof value === 'object') {
|
|
181
|
+
for (const child of Object.values(value))
|
|
182
|
+
freezeSchema(child);
|
|
183
|
+
Object.freeze(value);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
178
187
|
class ReadonlyMapView {
|
|
179
188
|
#values;
|
|
180
189
|
constructor(entries) {
|
package/lib/token.d.ts
CHANGED
|
@@ -7,12 +7,13 @@ export interface Token<T> {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function createToken<T>(id: string, description?: string): Token<T>;
|
|
9
9
|
export declare function createRootPrivateToken<T>(id: string, description?: string): Token<T>;
|
|
10
|
+
/** @internal Ends a facade's candidate declarations on seal or failed setup disposal. */
|
|
11
|
+
export declare const scopeSetupFinalizer: unique symbol;
|
|
10
12
|
export declare class Scope {
|
|
11
13
|
#private;
|
|
12
14
|
readonly owner: PluginId;
|
|
13
|
-
readonly parent?: Scope | undefined;
|
|
14
15
|
readonly disposers: DisposeStack;
|
|
15
|
-
constructor(owner: PluginId, parent?: Scope
|
|
16
|
+
constructor(owner: PluginId, parent?: Scope, disposers?: DisposeStack);
|
|
16
17
|
provide<T>(token: Token<T>, value: T, dispose?: Dispose): void;
|
|
17
18
|
use<T>(token: Token<T>): T;
|
|
18
19
|
has<T>(token: Token<T>): boolean;
|
package/lib/token.js
CHANGED
|
@@ -9,16 +9,19 @@ export function createRootPrivateToken(id, description) {
|
|
|
9
9
|
rootPrivateTokens.add(token);
|
|
10
10
|
return Object.freeze(token);
|
|
11
11
|
}
|
|
12
|
+
/** @internal Ends a facade's candidate declarations on seal or failed setup disposal. */
|
|
13
|
+
export const scopeSetupFinalizer = Symbol('ScopeSetupFinalizer');
|
|
12
14
|
export class Scope {
|
|
13
15
|
owner;
|
|
14
|
-
parent;
|
|
15
16
|
disposers;
|
|
16
17
|
#bindings = new Map();
|
|
18
|
+
#parent;
|
|
19
|
+
#setupFinalizers = new Set();
|
|
17
20
|
#sealed = false;
|
|
18
21
|
constructor(owner, parent, disposers = new DisposeStack()) {
|
|
19
22
|
this.owner = owner;
|
|
20
|
-
this.parent = parent;
|
|
21
23
|
this.disposers = disposers;
|
|
24
|
+
this.#parent = parent;
|
|
22
25
|
}
|
|
23
26
|
provide(token, value, dispose) {
|
|
24
27
|
if (this.#sealed)
|
|
@@ -32,6 +35,14 @@ export class Scope {
|
|
|
32
35
|
rootPrivate: rootPrivateTokens.has(token),
|
|
33
36
|
token,
|
|
34
37
|
}));
|
|
38
|
+
if (value && typeof value === 'object') {
|
|
39
|
+
const finalize = value[scopeSetupFinalizer];
|
|
40
|
+
if (typeof finalize === 'function') {
|
|
41
|
+
const endSetup = (reason) => finalize.call(value, reason);
|
|
42
|
+
this.#setupFinalizers.add(endSetup);
|
|
43
|
+
this.disposers.add(() => endSetup('dispose'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
35
46
|
if (dispose)
|
|
36
47
|
this.disposers.add(dispose);
|
|
37
48
|
}
|
|
@@ -42,8 +53,8 @@ export class Scope {
|
|
|
42
53
|
return binding.value;
|
|
43
54
|
throw new Error(`Missing resource ${token.id} for ${this.owner}`);
|
|
44
55
|
}
|
|
45
|
-
const inherited = this
|
|
46
|
-
? this
|
|
56
|
+
const inherited = this.#parent
|
|
57
|
+
? this.#parent.#resolveForDescendant(token.id)
|
|
47
58
|
: undefined;
|
|
48
59
|
if (inherited?.status === 'found')
|
|
49
60
|
return inherited.binding.value;
|
|
@@ -53,19 +64,22 @@ export class Scope {
|
|
|
53
64
|
const binding = this.#bindings.get(token.id);
|
|
54
65
|
if (binding)
|
|
55
66
|
return !binding.rootPrivate || binding.token === token;
|
|
56
|
-
return this
|
|
57
|
-
? this
|
|
67
|
+
return this.#parent
|
|
68
|
+
? this.#parent.#resolveForDescendant(token.id)?.status === 'found'
|
|
58
69
|
: false;
|
|
59
70
|
}
|
|
60
71
|
seal() {
|
|
61
72
|
this.#sealed = true;
|
|
73
|
+
for (const finalize of this.#setupFinalizers)
|
|
74
|
+
finalize('seal');
|
|
75
|
+
this.#setupFinalizers.clear();
|
|
62
76
|
this.disposers.seal();
|
|
63
77
|
}
|
|
64
78
|
snapshot() {
|
|
65
79
|
if (!this.#sealed)
|
|
66
80
|
throw new Error(`Scope is not sealed: ${this.owner}`);
|
|
67
|
-
const result = this
|
|
68
|
-
? new Map(this
|
|
81
|
+
const result = this.#parent
|
|
82
|
+
? new Map(this.#parent.snapshot())
|
|
69
83
|
: new Map();
|
|
70
84
|
for (const [id, binding] of this.#bindings) {
|
|
71
85
|
if (!binding.rootPrivate)
|
|
@@ -80,6 +94,6 @@ export class Scope {
|
|
|
80
94
|
? Object.freeze({ status: 'blocked' })
|
|
81
95
|
: Object.freeze({ status: 'found', binding });
|
|
82
96
|
}
|
|
83
|
-
return this
|
|
97
|
+
return this.#parent ? this.#parent.#resolveForDescendant(id) : undefined;
|
|
84
98
|
}
|
|
85
99
|
}
|