@zhin.js/plugin-runtime 1.1.8 → 1.1.10

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.
@@ -1,10 +1,5 @@
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
1
  export declare class GenerationAdmissionGate {
7
- constructor();
2
+ #private;
8
3
  get active(): boolean;
9
4
  acquire(): (() => void) | undefined;
10
5
  /** Observe the first commit which publishes this generation. */
package/lib/admission.js CHANGED
@@ -1,32 +1,35 @@
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
- */
1
+ // Assigned once by the class static block. The closures are a package-private
2
+ // friend seam: lifecycle publication can mutate #private state without adding
3
+ // Runtime-only methods to the public gate contract.
4
+ let admissionLifecycle;
6
5
  export class GenerationAdmissionGate {
7
- constructor() {
8
- admissionState.set(this, { active: false });
9
- admissionActivationListeners.set(this, new Set());
10
- admissionDeactivationListeners.set(this, new Set());
6
+ #state = { active: false };
7
+ #owner;
8
+ #activationListeners = new Set();
9
+ #deactivationListeners = new Set();
10
+ static {
11
+ admissionLifecycle = {
12
+ assertClaimable: (gate, owner) => gate.#assertClaimable(owner),
13
+ assertOwner: (gate, owner) => gate.#assertOwner(owner),
14
+ claim: (gate, owner) => { gate.#owner = owner; },
15
+ activate: (gate, acquire) => gate.#activate(acquire),
16
+ deactivate: (gate) => gate.#deactivate(),
17
+ };
11
18
  }
12
19
  get active() {
13
- return admissionState.get(this)?.active === true;
20
+ return this.#state.active;
14
21
  }
15
22
  acquire() {
16
- const state = admissionState.get(this);
17
- return state?.active ? state.acquire?.() : undefined;
23
+ return this.#state.active ? this.#state.acquire?.() : undefined;
18
24
  }
19
25
  /** Observe the first commit which publishes this generation. */
20
26
  onActivate(listener) {
21
- const listeners = admissionActivationListeners.get(this);
22
- if (!listeners)
23
- throw new Error('Unknown generation admission gate');
24
27
  if (this.active) {
25
28
  listener();
26
29
  return () => undefined;
27
30
  }
28
- listeners.add(listener);
29
- return () => { listeners.delete(listener); };
31
+ this.#activationListeners.add(listener);
32
+ return () => { this.#activationListeners.delete(listener); };
30
33
  }
31
34
  /**
32
35
  * Observe retirement of the generation that owns this gate. The callback is
@@ -34,15 +37,12 @@ export class GenerationAdmissionGate {
34
37
  * ingress transports such as WebSocket connections.
35
38
  */
36
39
  onDeactivate(listener) {
37
- const listeners = admissionDeactivationListeners.get(this);
38
- if (!listeners)
39
- throw new Error('Unknown generation admission gate');
40
40
  if (!this.active) {
41
41
  listener();
42
42
  return () => undefined;
43
43
  }
44
- listeners.add(listener);
45
- return () => { listeners.delete(listener); };
44
+ this.#deactivationListeners.add(listener);
45
+ return () => { this.#deactivationListeners.delete(listener); };
46
46
  }
47
47
  enter(operation) {
48
48
  const release = this.acquire();
@@ -60,11 +60,42 @@ export class GenerationAdmissionGate {
60
60
  throw error;
61
61
  }
62
62
  }
63
+ #assertClaimable(owner) {
64
+ if (this.#owner && this.#owner !== owner) {
65
+ throw new Error('Generation admission gate belongs to another SnapshotStore');
66
+ }
67
+ }
68
+ #assertOwner(owner) {
69
+ if (this.#owner !== owner) {
70
+ throw new Error('SnapshotStore does not own generation admission gate');
71
+ }
72
+ }
73
+ #activate(acquire) {
74
+ this.#state = { active: true, acquire };
75
+ for (const listener of this.#activationListeners) {
76
+ try {
77
+ listener();
78
+ }
79
+ catch {
80
+ // Admission publication is an infallible pointer switch. Buffered
81
+ // ingress reports through its own dispatch path.
82
+ }
83
+ }
84
+ this.#activationListeners.clear();
85
+ }
86
+ #deactivate() {
87
+ this.#state = { active: false };
88
+ for (const listener of this.#deactivationListeners) {
89
+ try {
90
+ listener();
91
+ }
92
+ catch {
93
+ // Admission publication is an infallible pointer switch. Transport
94
+ // cleanup reports through its own lifecycle and cannot veto commit.
95
+ }
96
+ }
97
+ }
63
98
  }
64
- const admissionState = new WeakMap();
65
- const admissionOwners = new WeakMap();
66
- const admissionActivationListeners = new WeakMap();
67
- const admissionDeactivationListeners = new WeakMap();
68
99
  export function createGenerationAdmissionGate() {
69
100
  return new GenerationAdmissionGate();
70
101
  }
@@ -103,50 +134,19 @@ export function replaceGenerationAdmissions(previous, next, owner, acquireNext)
103
134
  // Validate the complete switch before changing ownership or visibility. A
104
135
  // rejected cross-Root gate must leave both generations exactly untouched.
105
136
  for (const gate of next)
106
- assertClaimableAdmission(gate, owner);
137
+ admissionLifecycle.assertClaimable(gate, owner);
107
138
  for (const gate of previous) {
108
- assertAdmissionOwner(gate, owner);
139
+ admissionLifecycle.assertOwner(gate, owner);
109
140
  }
110
141
  for (const gate of next)
111
- admissionOwners.set(gate, owner);
142
+ admissionLifecycle.claim(gate, owner);
112
143
  for (const gate of previous) {
113
144
  if (next.has(gate))
114
145
  continue;
115
- admissionState.set(gate, { active: false });
116
- for (const listener of admissionDeactivationListeners.get(gate) ?? []) {
117
- try {
118
- listener();
119
- }
120
- catch {
121
- // Admission publication is an infallible pointer switch. Transport
122
- // cleanup reports through its own lifecycle and cannot veto commit.
123
- }
124
- }
146
+ admissionLifecycle.deactivate(gate);
125
147
  }
126
148
  for (const gate of next) {
127
- admissionState.set(gate, { active: true, acquire: acquireNext });
128
- const listeners = admissionActivationListeners.get(gate);
129
- for (const listener of listeners ?? []) {
130
- try {
131
- listener();
132
- }
133
- catch {
134
- // Admission publication is an infallible pointer switch. Buffered
135
- // ingress reports through its own dispatch path.
136
- }
137
- }
138
- listeners?.clear();
139
- }
140
- }
141
- function assertClaimableAdmission(gate, owner) {
142
- const current = admissionOwners.get(gate);
143
- if (current && current !== owner) {
144
- throw new Error('Generation admission gate belongs to another SnapshotStore');
145
- }
146
- }
147
- function assertAdmissionOwner(gate, owner) {
148
- if (admissionOwners.get(gate) !== owner) {
149
- throw new Error('SnapshotStore does not own generation admission gate');
149
+ admissionLifecycle.activate(gate, acquireNext);
150
150
  }
151
151
  }
152
152
  function isObject(value) {
@@ -39,7 +39,7 @@ export interface TemplateContext {
39
39
  /**
40
40
  * Thin Host Resource for Plugin Runtime outbound template compilation.
41
41
  * Evaluates `${expr}` template expressions in a sandboxed VM context
42
- * (same security boundary as the legacy ComponentFeature pipeline).
42
+ * The Host keeps render authority inside the active Runtime operation.
43
43
  *
44
44
  * Implemented by the CLI Host; absent when not wired — consumers must
45
45
  * pass text through unchanged.
@@ -1,4 +1,5 @@
1
- import { type PluginId } from './identity.js';
1
+ import type { PluginId } from './identity.js';
2
+ import { OwnerScopedResourceHost } from './owner-scoped-resource-host.js';
2
3
  export interface DatabaseHostSelection {
3
4
  where(query: Record<string, unknown>): DatabaseHostSelection;
4
5
  orderBy(field: string, direction?: 'ASC' | 'DESC'): DatabaseHostSelection;
@@ -96,32 +97,23 @@ export interface DatabaseHost {
96
97
  * the Runtime maps them to private physical table names before they reach the
97
98
  * process-wide DatabaseHost.
98
99
  */
99
- export interface PluginDatabaseHost {
100
- readonly owner: PluginId;
101
- readonly dialect: string;
102
- readonly started: boolean;
103
- define(name: string, definition: Record<string, unknown>): void;
104
- tables(): readonly string[];
105
- models: {
106
- get(name: string): DatabaseHostModel | undefined;
107
- };
108
- }
109
100
  /**
110
101
  * Maps a plugin's logical resource name to its process-wide physical name.
111
- * Root keeps its historical bare names so existing projects do not need a
112
- * database migration merely to adopt scoped child plugins.
102
+ * Every owner, including the root plugin, uses the same private namespace.
113
103
  */
114
104
  export declare function qualifyPluginResourceName(owner: PluginId, name: string): string;
115
- /** Reverse `qualifyPluginResourceName` only when the name belongs to owner. */
116
- export declare function unqualifyPluginResourceName(owner: PluginId, name: string): string | undefined;
117
- /** Creates a tenant facade without exposing Console administration or raw DB access. */
118
- export declare function createPluginDatabaseHost(owner: PluginId, host: DatabaseHost): PluginDatabaseHost;
119
- /**
120
- * Recovers a process host from the standard root token or a scoped facade.
121
- * This keeps custom RootRuntime installers written against the pre-facade
122
- * token working while child scopes begin receiving tenant boundaries.
123
- */
124
- export declare function unwrapPluginDatabaseHost(host: DatabaseHost | PluginDatabaseHost): DatabaseHost | undefined;
105
+ /** Owner-bound persistence facade without Console administration or raw DB access. */
106
+ export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
107
+ private readonly host;
108
+ readonly models: {
109
+ get(name: string): DatabaseHostModel | undefined;
110
+ };
111
+ constructor(owner: PluginId, host: DatabaseHost);
112
+ get dialect(): string;
113
+ get started(): boolean;
114
+ define(name: string, definition: Record<string, unknown>): void;
115
+ tables(): readonly string[];
116
+ }
125
117
  /**
126
118
  * Plugin-facing scoped persistence token. Runtime replaces this binding for
127
119
  * every Plugin Scope; plugin code must never receive the process-wide host.
@@ -1,63 +1,40 @@
1
- import { pluginOwnerResourceKey, rootPluginId } from './identity.js';
1
+ import { OwnerScopedResourceHost, qualifyOwnedResourceName, } from './owner-scoped-resource-host.js';
2
2
  import { createToken } from './token.js';
3
- const resourcePrefix = '__zhin_plugin__';
4
- const resourceSeparator = '__';
5
- const roots = new WeakMap();
6
3
  /**
7
- * Maps a plugin's logical resource name to its process-wide physical name.
8
- * Root keeps its historical bare names so existing projects do not need a
9
- * database migration merely to adopt scoped child plugins.
4
+ * Persistence surface visible to a plugin. Names are logical to the owner;
5
+ * the Runtime maps them to private physical table names before they reach the
6
+ * process-wide DatabaseHost.
10
7
  */
11
- export function qualifyPluginResourceName(owner, name) {
12
- assertLogicalResourceName(name);
13
- if (owner === rootPluginId())
14
- return name;
15
- return `${resourcePrefix}${pluginOwnerResourceKey(owner)}${resourceSeparator}${name}`;
16
- }
17
- /** Reverse `qualifyPluginResourceName` only when the name belongs to owner. */
18
- export function unqualifyPluginResourceName(owner, name) {
19
- if (owner === rootPluginId())
20
- return name.startsWith(resourcePrefix) ? undefined : name;
21
- const prefix = `${resourcePrefix}${pluginOwnerResourceKey(owner)}${resourceSeparator}`;
22
- return name.startsWith(prefix) ? name.slice(prefix.length) : undefined;
23
- }
24
- /** Creates a tenant facade without exposing Console administration or raw DB access. */
25
- export function createPluginDatabaseHost(owner, host) {
26
- const facade = Object.freeze({
27
- owner,
28
- get dialect() { return host.dialect; },
29
- get started() { return host.started; },
30
- define(name, definition) {
31
- host.define(qualifyPluginResourceName(owner, name), definition);
32
- },
33
- tables() {
34
- return Object.freeze(host.tables().flatMap((name) => {
35
- const logical = unqualifyPluginResourceName(owner, name);
36
- return logical === undefined ? [] : [logical];
37
- }));
38
- },
39
- models: Object.freeze({
40
- get(name) {
41
- return host.models.get(qualifyPluginResourceName(owner, name));
42
- },
43
- }),
44
- });
45
- roots.set(facade, host);
46
- return facade;
47
- }
48
8
  /**
49
- * Recovers a process host from the standard root token or a scoped facade.
50
- * This keeps custom RootRuntime installers written against the pre-facade
51
- * token working while child scopes begin receiving tenant boundaries.
9
+ * Maps a plugin's logical resource name to its process-wide physical name.
10
+ * Every owner, including the root plugin, uses the same private namespace.
52
11
  */
53
- export function unwrapPluginDatabaseHost(host) {
54
- if ('getRawDatabase' in host)
55
- return host;
56
- return roots.get(host);
12
+ export function qualifyPluginResourceName(owner, name) {
13
+ return qualifyOwnedResourceName(owner, name);
57
14
  }
58
- function assertLogicalResourceName(name) {
59
- if (!name || name.startsWith(resourcePrefix)) {
60
- throw new TypeError(`Invalid plugin resource name: ${name}`);
15
+ /** Owner-bound persistence facade without Console administration or raw DB access. */
16
+ export class PluginDatabaseHost extends OwnerScopedResourceHost {
17
+ host;
18
+ models;
19
+ constructor(owner, host) {
20
+ super(owner);
21
+ this.host = host;
22
+ this.models = Object.freeze({ get: (name) => this.host.models.get(this.qualify(name)) });
23
+ }
24
+ get dialect() {
25
+ return this.host.dialect;
26
+ }
27
+ get started() {
28
+ return this.host.started;
29
+ }
30
+ define(name, definition) {
31
+ this.host.define(this.qualify(name), definition);
32
+ }
33
+ tables() {
34
+ return Object.freeze(this.host.tables().flatMap((name) => {
35
+ const logical = this.unqualify(name);
36
+ return logical === undefined ? [] : [logical];
37
+ }));
61
38
  }
62
39
  }
63
40
  /**
@@ -1,10 +1,11 @@
1
1
  export interface HtmlRenderOptions {
2
2
  readonly width?: number;
3
+ /** 兼容旧调用方;当前实现传 `svg` 也会降级为 png。 */
3
4
  readonly format?: 'png' | 'svg';
4
5
  readonly backgroundColor?: string;
5
6
  }
6
7
  export interface HtmlRenderResult {
7
- /** PNG 时为 Buffer/Uint8Array,SVG 时为 string。 */
8
+ /** 当前实现恒为 png:Buffer/Uint8Array。 */
8
9
  readonly data: unknown;
9
10
  readonly format: 'png' | 'svg';
10
11
  readonly width: number;
package/lib/inbox.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { DatabaseHost } from './database-host.js';
5
5
  * 列结构对齐 packages/im/zhin/src/setup/register-inbox.ts(legacy 读路径 SSOT),
6
6
  * request/notice 额外补 consumed/consumed_at 列(对齐 legacy host-api
7
7
  * endpoint-persistence 的 console_bot_requests/notices consumed 标记方式),
8
- * 供 console-rpc-extended 的 endpoint:requestConsumed/noticeConsumed 写路径使用。
8
+ * 供 console-rpc-extended 的 request.consumed / notice.consumed 写路径使用。
9
9
  */
10
10
  export declare const INBOX_TABLE_MESSAGE = "unified_inbox_message";
11
11
  export declare const INBOX_TABLE_REQUEST = "unified_inbox_request";
package/lib/inbox.js CHANGED
@@ -5,7 +5,7 @@ import { createToken } from './token.js';
5
5
  * 列结构对齐 packages/im/zhin/src/setup/register-inbox.ts(legacy 读路径 SSOT),
6
6
  * request/notice 额外补 consumed/consumed_at 列(对齐 legacy host-api
7
7
  * endpoint-persistence 的 console_bot_requests/notices consumed 标记方式),
8
- * 供 console-rpc-extended 的 endpoint:requestConsumed/noticeConsumed 写路径使用。
8
+ * 供 console-rpc-extended 的 request.consumed / notice.consumed 写路径使用。
9
9
  */
10
10
  export const INBOX_TABLE_MESSAGE = 'unified_inbox_message';
11
11
  export const INBOX_TABLE_REQUEST = 'unified_inbox_request';
package/lib/index.d.ts CHANGED
@@ -7,10 +7,13 @@ export * from './capability.js';
7
7
  export * from './dispose.js';
8
8
  export * from './identity.js';
9
9
  export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionBindable, type GenerationAdmissionSource, } from './admission.js';
10
- export * from './generation-store.js';
11
10
  export * from './handoff.js';
12
11
  /** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
13
12
  export * from './plugin.js';
13
+ /** @public Root configuration contract shared by Runtime and composition tooling. */
14
+ export * from './plugin-configuration.js';
15
+ export * from './root-config-file.js';
16
+ export * from './root-config-document.js';
14
17
  /** @internal Runtime 内部:RootRuntime / RootController。 */
15
18
  export * from './root-controller.js';
16
19
  export * from './shared-lifetime.js';
package/lib/index.js CHANGED
@@ -7,10 +7,13 @@ export * from './capability.js';
7
7
  export * from './dispose.js';
8
8
  export * from './identity.js';
9
9
  export { GenerationAdmissionGate, bindGenerationAdmission, createGenerationAdmissionGate, generationAdmissionBinder, generationAdmissionSource, } from './admission.js';
10
- export * from './generation-store.js';
11
10
  export * from './handoff.js';
12
11
  /** @public 用户侧创作面:`definePlugin`(`plugin.ts` 约定入口,承诺 semver)。 */
13
12
  export * from './plugin.js';
13
+ /** @public Root configuration contract shared by Runtime and composition tooling. */
14
+ export * from './plugin-configuration.js';
15
+ export * from './root-config-file.js';
16
+ export * from './root-config-document.js';
14
17
  /** @internal Runtime 内部:RootRuntime / RootController。 */
15
18
  export * from './root-controller.js';
16
19
  export * from './shared-lifetime.js';
@@ -0,0 +1,9 @@
1
+ import { type PluginId } from './identity.js';
2
+ /** Shared owner identity and physical-name invariant for process-wide resources. */
3
+ export declare abstract class OwnerScopedResourceHost {
4
+ readonly owner: PluginId;
5
+ constructor(owner: PluginId);
6
+ protected qualify(name: string): string;
7
+ protected unqualify(name: string): string | undefined;
8
+ }
9
+ export declare function qualifyOwnedResourceName(owner: PluginId, name: string): string;
@@ -0,0 +1,27 @@
1
+ import { pluginOwnerResourceKey } from './identity.js';
2
+ const resourcePrefix = '__zhin_plugin__';
3
+ const resourceSeparator = '__';
4
+ /** Shared owner identity and physical-name invariant for process-wide resources. */
5
+ export class OwnerScopedResourceHost {
6
+ owner;
7
+ constructor(owner) {
8
+ this.owner = owner;
9
+ }
10
+ qualify(name) {
11
+ assertLogicalName(name);
12
+ return qualifyOwnedResourceName(this.owner, name);
13
+ }
14
+ unqualify(name) {
15
+ const prefix = `${resourcePrefix}${pluginOwnerResourceKey(this.owner)}${resourceSeparator}`;
16
+ return name.startsWith(prefix) ? name.slice(prefix.length) : undefined;
17
+ }
18
+ }
19
+ export function qualifyOwnedResourceName(owner, name) {
20
+ assertLogicalName(name);
21
+ return `${resourcePrefix}${pluginOwnerResourceKey(owner)}${resourceSeparator}${name}`;
22
+ }
23
+ function assertLogicalName(name) {
24
+ if (!name || name.startsWith(resourcePrefix)) {
25
+ throw new TypeError(`Invalid plugin resource name: ${name}`);
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ /** Canonical `zhin.config.*#plugins` shape keyed by Plugin instanceKey. */
2
+ export type PluginConfigurationMap = Readonly<Record<string, unknown>>;
3
+ /** Raised when a Root config still uses a non-canonical Plugin configuration shape. */
4
+ export declare class PluginConfigurationShapeError extends TypeError {
5
+ readonly source: string;
6
+ constructor(source?: string);
7
+ }
8
+ /**
9
+ * Reads the canonical Plugin instance configuration map.
10
+ *
11
+ * An omitted `plugins` key means that the Root has no configured child instances.
12
+ * Every present value must be an object map; legacy package-name arrays belong only
13
+ * to the explicit migration pipeline.
14
+ */
15
+ export declare function readPluginConfigurationMap(document: Readonly<Record<string, unknown>>, source?: string): PluginConfigurationMap;
@@ -0,0 +1,26 @@
1
+ const EMPTY_PLUGIN_CONFIGURATION = Object.freeze({});
2
+ /** Raised when a Root config still uses a non-canonical Plugin configuration shape. */
3
+ export class PluginConfigurationShapeError extends TypeError {
4
+ source;
5
+ constructor(source = 'Root config') {
6
+ super(`${source}.plugins must be an object keyed by Plugin instanceKey`);
7
+ this.source = source;
8
+ this.name = 'PluginConfigurationShapeError';
9
+ }
10
+ }
11
+ /**
12
+ * Reads the canonical Plugin instance configuration map.
13
+ *
14
+ * An omitted `plugins` key means that the Root has no configured child instances.
15
+ * Every present value must be an object map; legacy package-name arrays belong only
16
+ * to the explicit migration pipeline.
17
+ */
18
+ export function readPluginConfigurationMap(document, source = 'Root config') {
19
+ const value = document.plugins;
20
+ if (value === undefined)
21
+ return EMPTY_PLUGIN_CONFIGURATION;
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
23
+ throw new PluginConfigurationShapeError(source);
24
+ }
25
+ return value;
26
+ }
package/lib/plugin.d.ts CHANGED
@@ -56,10 +56,10 @@ export interface PluginDefinition<TConfig = unknown> {
56
56
  * });
57
57
  * ```
58
58
  *
59
- * Put capabilities in convention files such as `commands/hello.ts`, each with
59
+ * Put capabilities in convention files such as `commands/hello/index.ts`, each with
60
60
  * one default export.
61
61
  */
62
62
  export declare function definePlugin<TConfig = unknown>(definition: PluginDefinition<TConfig>): Readonly<PluginDefinition<TConfig>>;
63
- export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, type PluginDatabaseHost, } from './database-host.js';
63
+ export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, PluginDatabaseHost, } from './database-host.js';
64
64
  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
- export { scheduleHostToken, type PluginScheduleHost, type ScheduleJobRegistration, } from './schedule-host.js';
65
+ export { scheduleHostToken, PluginScheduleHost, type ScheduleJobRegistration, } from './schedule-host.js';
package/lib/plugin.js CHANGED
@@ -14,7 +14,7 @@ const pluginNamePattern = /^[a-z][a-z0-9-]*$/;
14
14
  * });
15
15
  * ```
16
16
  *
17
- * Put capabilities in convention files such as `commands/hello.ts`, each with
17
+ * Put capabilities in convention files such as `commands/hello/index.ts`, each with
18
18
  * one default export.
19
19
  */
20
20
  export function definePlugin(definition) {
@@ -28,6 +28,6 @@ export function definePlugin(definition) {
28
28
  });
29
29
  }
30
30
  // The application facade exposes these Host contracts beside definePlugin.
31
- export { databaseHostToken, } from './database-host.js';
31
+ export { databaseHostToken, PluginDatabaseHost, } from './database-host.js';
32
32
  export { outboundHostToken, } from './outbound-host.js';
33
- export { scheduleHostToken, } from './schedule-host.js';
33
+ export { scheduleHostToken, PluginScheduleHost, } from './schedule-host.js';
@@ -0,0 +1,33 @@
1
+ export type RuntimeConfigDocument = Readonly<Record<string, unknown>>;
2
+ export type ConfigPatch = {
3
+ readonly op: 'set';
4
+ readonly path: readonly string[];
5
+ readonly value: unknown;
6
+ } | {
7
+ readonly op: 'remove';
8
+ readonly path: readonly string[];
9
+ };
10
+ export interface ConfigDocumentSnapshot {
11
+ readonly document: RuntimeConfigDocument;
12
+ readonly revision: string;
13
+ }
14
+ /** A prepared write is inert until the generation handoff activates it. */
15
+ export interface PreparedConfigDocument {
16
+ readonly document: RuntimeConfigDocument;
17
+ commit(): Promise<ConfigDocumentSnapshot>;
18
+ rollback(): Promise<void>;
19
+ }
20
+ export interface ConfigDocumentPort {
21
+ /** Files whose external edits invalidate this document snapshot. */
22
+ readonly sources?: readonly string[];
23
+ read(): Promise<ConfigDocumentSnapshot>;
24
+ prepare(current: ConfigDocumentSnapshot, patches: readonly ConfigPatch[]): Promise<PreparedConfigDocument>;
25
+ }
26
+ export declare class ConfigDocumentDivergenceError extends Error {
27
+ constructor();
28
+ }
29
+ export declare class ConfigPatchPathError extends Error {
30
+ constructor(message: string);
31
+ }
32
+ /** Applies structural patches without mutating the source document. */
33
+ export declare function applyConfigPatches(document: RuntimeConfigDocument, patches: readonly ConfigPatch[]): RuntimeConfigDocument;
@@ -0,0 +1,116 @@
1
+ export class ConfigDocumentDivergenceError extends Error {
2
+ constructor() {
3
+ super('ConfigDocument adapter candidate differs from the validated Runtime candidate');
4
+ this.name = 'ConfigDocumentDivergenceError';
5
+ }
6
+ }
7
+ export class ConfigPatchPathError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'ConfigPatchPathError';
11
+ }
12
+ }
13
+ /** Applies structural patches without mutating the source document. */
14
+ export function applyConfigPatches(document, patches) {
15
+ let candidate = structuredClone(document);
16
+ for (const patch of patches)
17
+ candidate = applyConfigPatch(candidate, patch);
18
+ return Object.freeze(candidate);
19
+ }
20
+ function applyConfigPatch(document, patch) {
21
+ assertPath(patch.path);
22
+ if (patch.path.length === 0) {
23
+ if (patch.op === 'remove') {
24
+ throw new ConfigPatchPathError('The config document root cannot be removed');
25
+ }
26
+ return cloneDocument(patch.value);
27
+ }
28
+ if (patch.op === 'set')
29
+ setValue(document, patch.path, structuredClone(patch.value));
30
+ else
31
+ removeValue(document, patch.path);
32
+ return document;
33
+ }
34
+ function setValue(document, path, value) {
35
+ let target = document;
36
+ for (const [index, segment] of path.slice(0, -1).entries()) {
37
+ const existing = readChild(target, segment, path.slice(0, index + 1));
38
+ if (existing === undefined) {
39
+ const created = {};
40
+ target[segment] = created;
41
+ target = created;
42
+ }
43
+ else {
44
+ target = requireContainer(existing, path.slice(0, index + 1));
45
+ }
46
+ }
47
+ writeChild(target, lastSegment(path), value, path);
48
+ }
49
+ function removeValue(document, path) {
50
+ let target = document;
51
+ for (const [index, segment] of path.slice(0, -1).entries()) {
52
+ const existing = readChild(target, segment, path.slice(0, index + 1));
53
+ if (existing === undefined)
54
+ return;
55
+ target = requireContainer(existing, path.slice(0, index + 1));
56
+ }
57
+ const segment = lastSegment(path);
58
+ if (Array.isArray(target)) {
59
+ target.splice(arrayIndex(segment, target, path), 1);
60
+ return;
61
+ }
62
+ delete target[segment];
63
+ }
64
+ function cloneDocument(value) {
65
+ const document = structuredClone(value);
66
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
67
+ throw new ConfigPatchPathError('The config document root must be an object');
68
+ }
69
+ return document;
70
+ }
71
+ function readChild(container, segment, path) {
72
+ if (Array.isArray(container))
73
+ return container[arrayIndex(segment, container, path)];
74
+ return container[segment];
75
+ }
76
+ function writeChild(container, segment, value, path) {
77
+ if (Array.isArray(container)) {
78
+ container[arrayIndex(segment, container, path)] = value;
79
+ return;
80
+ }
81
+ container[segment] = value;
82
+ }
83
+ function arrayIndex(segment, container, path) {
84
+ if (!/^(0|[1-9]\d*)$/.test(segment)) {
85
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} requires an array index, got "${segment}"`);
86
+ }
87
+ const index = Number(segment);
88
+ if (index >= container.length) {
89
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} is out of bounds (array length ${container.length})`);
90
+ }
91
+ return index;
92
+ }
93
+ function requireContainer(value, path) {
94
+ if (!value || typeof value !== 'object') {
95
+ throw new ConfigPatchPathError(`Config path ${pointer(path)} is not an object`);
96
+ }
97
+ return value;
98
+ }
99
+ function assertPath(path) {
100
+ for (const segment of path) {
101
+ if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
102
+ throw new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
103
+ }
104
+ }
105
+ }
106
+ function lastSegment(path) {
107
+ const segment = path[path.length - 1];
108
+ if (!segment)
109
+ throw new ConfigPatchPathError('Config patch path is empty');
110
+ return segment;
111
+ }
112
+ function pointer(path) {
113
+ if (path.length === 0)
114
+ return '/';
115
+ return `/${path.map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
116
+ }
@@ -0,0 +1,9 @@
1
+ export declare const ROOT_CONFIG_FILE_NAMES: readonly ["config.yml", "config.yaml", "config.json", "zhin.config.yml", "zhin.config.yaml", "zhin.config.json"];
2
+ export type RootConfigFileName = typeof ROOT_CONFIG_FILE_NAMES[number];
3
+ export type RootConfigFormat = 'yaml' | 'json';
4
+ /** File materialized when a project has no Root configuration yet. */
5
+ export declare const DEFAULT_ROOT_CONFIG_FILE_NAME: RootConfigFileName;
6
+ /** Returns the only configuration formats accepted by Root Runtime. */
7
+ export declare function rootConfigFormat(filePath: string): RootConfigFormat | undefined;
8
+ /** Enforces a single Root configuration authority for one project. */
9
+ export declare function selectRootConfigFile(existingFiles: readonly string[]): string | undefined;
@@ -0,0 +1,29 @@
1
+ export const ROOT_CONFIG_FILE_NAMES = Object.freeze([
2
+ 'config.yml',
3
+ 'config.yaml',
4
+ 'config.json',
5
+ 'zhin.config.yml',
6
+ 'zhin.config.yaml',
7
+ 'zhin.config.json',
8
+ ]);
9
+ /** File materialized when a project has no Root configuration yet. */
10
+ export const DEFAULT_ROOT_CONFIG_FILE_NAME = 'zhin.config.yml';
11
+ /** Returns the only configuration formats accepted by Root Runtime. */
12
+ export function rootConfigFormat(filePath) {
13
+ const fileName = filePath.split(/[\\/]/u).pop();
14
+ if (!fileName || !ROOT_CONFIG_FILE_NAMES.includes(fileName))
15
+ return undefined;
16
+ const extension = /\.[^.]+$/u.exec(fileName)?.[0];
17
+ if (extension === '.yml' || extension === '.yaml')
18
+ return 'yaml';
19
+ if (extension === '.json')
20
+ return 'json';
21
+ return undefined;
22
+ }
23
+ /** Enforces a single Root configuration authority for one project. */
24
+ export function selectRootConfigFile(existingFiles) {
25
+ if (existingFiles.length > 1) {
26
+ throw new Error(`Multiple Root config files found: ${existingFiles.join(', ')}`);
27
+ }
28
+ return existingFiles[0];
29
+ }
@@ -1,4 +1,5 @@
1
- import { type PluginId } from './identity.js';
1
+ import type { PluginId } from './identity.js';
2
+ import { OwnerScopedResourceHost } from './owner-scoped-resource-host.js';
2
3
  /**
3
4
  * Thin Host Resource for Plugin Runtime cron jobs.
4
5
  * Implementations typically wrap `@zhin.js/schedule` CalendarScheduler (solar cron).
@@ -23,8 +24,10 @@ export interface ScheduleHost {
23
24
  }[];
24
25
  }
25
26
  /** Scoped scheduling surface exposed to a Plugin setup function. */
26
- export interface PluginScheduleHost {
27
- readonly owner: PluginId;
27
+ export declare function qualifyPluginScheduleId(owner: PluginId, id: string): string;
28
+ export declare class PluginScheduleHost extends OwnerScopedResourceHost {
29
+ private readonly host;
30
+ constructor(owner: PluginId, host: ScheduleHost);
28
31
  register(job: ScheduleJobRegistration): () => void;
29
32
  list(): readonly {
30
33
  readonly id: string;
@@ -32,11 +35,6 @@ export interface PluginScheduleHost {
32
35
  readonly description?: string;
33
36
  }[];
34
37
  }
35
- export declare function qualifyPluginScheduleId(owner: PluginId, id: string): string;
36
- export declare function unqualifyPluginScheduleId(owner: PluginId, id: string): string | undefined;
37
- export declare function createPluginScheduleHost(owner: PluginId, host: ScheduleHost): PluginScheduleHost;
38
- /** See unwrapPluginDatabaseHost: preserves legacy custom RootResource installers. */
39
- export declare function unwrapPluginScheduleHost(host: ScheduleHost | PluginScheduleHost): ScheduleHost;
40
38
  /** @public Plugin-facing token; Runtime injects a facade bound to the current owner. */
41
39
  export declare const scheduleHostToken: import("./token.js").Token<PluginScheduleHost>;
42
40
  /** Root-only process scheduler for CLI composition and Console administration. */
@@ -1,49 +1,29 @@
1
- import { pluginOwnerResourceKey, rootPluginId } from './identity.js';
1
+ import { OwnerScopedResourceHost, qualifyOwnedResourceName, } from './owner-scoped-resource-host.js';
2
2
  import { createToken } from './token.js';
3
- const jobPrefix = '__zhin_plugin__';
4
- const jobSeparator = '__';
5
- const roots = new WeakMap();
3
+ /** Scoped scheduling surface exposed to a Plugin setup function. */
6
4
  export function qualifyPluginScheduleId(owner, id) {
7
- assertLogicalJobId(id);
8
- if (owner === rootPluginId())
9
- return id;
10
- return `${jobPrefix}${pluginOwnerResourceKey(owner)}${jobSeparator}${id}`;
5
+ return qualifyOwnedResourceName(owner, id);
11
6
  }
12
- export function unqualifyPluginScheduleId(owner, id) {
13
- if (owner === rootPluginId())
14
- return id.startsWith(jobPrefix) ? undefined : id;
15
- const prefix = `${jobPrefix}${pluginOwnerResourceKey(owner)}${jobSeparator}`;
16
- return id.startsWith(prefix) ? id.slice(prefix.length) : undefined;
17
- }
18
- export function createPluginScheduleHost(owner, host) {
19
- const facade = Object.freeze({
20
- owner,
21
- register(job) {
22
- return host.register({ ...job, id: qualifyPluginScheduleId(owner, job.id), owner });
23
- },
24
- list() {
25
- return Object.freeze(host.list().flatMap((job) => {
26
- const id = unqualifyPluginScheduleId(owner, job.id);
27
- if (id === undefined)
28
- return [];
29
- return [Object.freeze({
30
- id,
31
- cron: job.cron,
32
- ...(job.description === undefined ? {} : { description: job.description }),
33
- })];
34
- }));
35
- },
36
- });
37
- roots.set(facade, host);
38
- return facade;
39
- }
40
- /** See unwrapPluginDatabaseHost: preserves legacy custom RootResource installers. */
41
- export function unwrapPluginScheduleHost(host) {
42
- return roots.get(host) ?? host;
43
- }
44
- function assertLogicalJobId(id) {
45
- if (!id || id.startsWith(jobPrefix)) {
46
- throw new TypeError(`Invalid plugin schedule id: ${id}`);
7
+ export class PluginScheduleHost extends OwnerScopedResourceHost {
8
+ host;
9
+ constructor(owner, host) {
10
+ super(owner);
11
+ this.host = host;
12
+ }
13
+ register(job) {
14
+ return this.host.register({ ...job, id: this.qualify(job.id), owner: this.owner });
15
+ }
16
+ list() {
17
+ return Object.freeze(this.host.list().flatMap((job) => {
18
+ const id = this.unqualify(job.id);
19
+ if (id === undefined)
20
+ return [];
21
+ return [Object.freeze({
22
+ id,
23
+ cron: job.cron,
24
+ ...(job.description === undefined ? {} : { description: job.description }),
25
+ })];
26
+ }));
47
27
  }
48
28
  }
49
29
  /** @public Plugin-facing token; Runtime injects a facade bound to the current owner. */
@@ -2,8 +2,7 @@ import type { DatabaseHost } from './database-host.js';
2
2
  /**
3
3
  * 系统日志表(console logs 页数据源)定义与写入辅助。
4
4
  *
5
- * 列结构对齐 packages/im/core/src/models/system-log.ts(legacy SystemLogDefinition,
6
- * 读取端为 packages/host/http console-rest-pages registerLogsRoutes)。
5
+ * 读取端为 packages/host/http console-rest-pages registerLogsRoutes。
7
6
  */
8
7
  export declare const SYSTEM_LOG_TABLE = "SystemLog";
9
8
  export declare const SYSTEM_LOG_DEFINITION: Record<string, unknown>;
package/lib/system-log.js CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * 系统日志表(console logs 页数据源)定义与写入辅助。
3
3
  *
4
- * 列结构对齐 packages/im/core/src/models/system-log.ts(legacy SystemLogDefinition,
5
- * 读取端为 packages/host/http console-rest-pages registerLogsRoutes)。
4
+ * 读取端为 packages/host/http console-rest-pages registerLogsRoutes。
6
5
  */
7
6
  export const SYSTEM_LOG_TABLE = 'SystemLog';
8
7
  export const SYSTEM_LOG_DEFINITION = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-runtime",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -1,32 +0,0 @@
1
- import { DisposeStack, type Dispose } from './dispose.js';
2
- /**
3
- * Minimal structural view of a plugin setup context: anything owning a
4
- * generation-scoped lifecycle (e.g. PluginSetupContext) can provide values.
5
- */
6
- export interface GenerationStoreContext {
7
- readonly lifecycle: DisposeStack;
8
- }
9
- export interface GenerationStore<T> {
10
- /**
11
- * Publish `value` as the current generation's value. The registration is
12
- * removed automatically when `context.lifecycle` disposes (generation end);
13
- * the returned Dispose unregisters manually and is idempotent.
14
- */
15
- provide(context: GenerationStoreContext, value: T): Dispose;
16
- /** Latest live value; throws a store-named error when none was provided. */
17
- use(): T;
18
- /** Latest live value, or `undefined` when none was provided. */
19
- tryUse(): T | undefined;
20
- /** Drop every registration across all generations. Mainly for tests. */
21
- clear(): void;
22
- }
23
- /**
24
- * Generation-scoped replacement for module-level `let _x` singletons.
25
- *
26
- * Provided values form a stack: the newest live registration wins, and when a
27
- * generation ends its registration is removed via `context.lifecycle`,
28
- * re-exposing the previous generation's value. This structurally prevents
29
- * stale references to an already disposed generation (the repeater singleton
30
- * / rss `_db` class of bugs).
31
- */
32
- export declare function createGenerationStore<T>(name: string): GenerationStore<T>;
@@ -1,38 +0,0 @@
1
- /**
2
- * Generation-scoped replacement for module-level `let _x` singletons.
3
- *
4
- * Provided values form a stack: the newest live registration wins, and when a
5
- * generation ends its registration is removed via `context.lifecycle`,
6
- * re-exposing the previous generation's value. This structurally prevents
7
- * stale references to an already disposed generation (the repeater singleton
8
- * / rss `_db` class of bugs).
9
- */
10
- export function createGenerationStore(name) {
11
- const registrations = [];
12
- const unregister = (registration) => {
13
- const index = registrations.lastIndexOf(registration);
14
- if (index >= 0)
15
- registrations.splice(index, 1);
16
- };
17
- return {
18
- provide(context, value) {
19
- const registration = Object.freeze({ value });
20
- registrations.push(registration);
21
- context.lifecycle.add(() => unregister(registration));
22
- return () => unregister(registration);
23
- },
24
- use() {
25
- const latest = registrations[registrations.length - 1];
26
- if (!latest) {
27
- throw new Error(`Generation store "${name}" has no live value — call provide(context, value) during plugin setup`);
28
- }
29
- return latest.value;
30
- },
31
- tryUse() {
32
- return registrations[registrations.length - 1]?.value;
33
- },
34
- clear() {
35
- registrations.length = 0;
36
- },
37
- };
38
- }