@zhin.js/plugin-runtime 1.1.16 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import { createGenerationAdmissionGate, generationAdmissionSource, } from './admission.js';
2
+ import { createToken } from './token.js';
3
+ /** Safe, user-facing failure. `message` is shown in the Console; never put raw errors in it. */
4
+ export class PluginConsoleRpcError extends Error {
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = 'PluginConsoleRpcError';
9
+ this.code = code;
10
+ }
11
+ }
12
+ export const pluginConsoleRpcToken = createToken('zhin.plugin-console-rpc', 'Plugin-owned Console status and limited actions, called through the authenticated Console RPC envelope');
13
+ export const PLUGIN_CONSOLE_RPC_STATUS = 'status';
14
+ const methodNamePattern = /^[a-z][a-z0-9-]{0,63}$/u;
15
+ const pageNamePattern = /^[a-z0-9][a-z0-9-]*$/u;
16
+ /** Validates and freezes a plugin's Console RPC surface. Throws on invalid names at setup time. */
17
+ export function definePluginConsoleRpc(definition) {
18
+ if (!definition || typeof definition !== 'object')
19
+ throw new TypeError('Plugin Console RPC definition must be an object');
20
+ const methods = new Map();
21
+ if (definition.status !== undefined) {
22
+ methods.set(PLUGIN_CONSOLE_RPC_STATUS, normalizeMethod('status', definition.status, definition.status.demo === true, true));
23
+ }
24
+ for (const [kind, entries] of [['query', definition.queries], ['action', definition.actions]]) {
25
+ for (const [name, method] of Object.entries(entries ?? {})) {
26
+ if (!methodNamePattern.test(name))
27
+ throw new TypeError(`Invalid plugin Console RPC ${kind} name: ${name}`);
28
+ if (name === PLUGIN_CONSOLE_RPC_STATUS)
29
+ throw new TypeError('"status" is reserved; declare it as definition.status');
30
+ if (methods.has(name))
31
+ throw new TypeError(`Duplicate plugin Console RPC method: ${name}`);
32
+ const readOnly = kind === 'query';
33
+ methods.set(name, normalizeMethod(name, method, readOnly && method.demo === true, readOnly));
34
+ }
35
+ }
36
+ if (methods.size === 0)
37
+ throw new TypeError('Plugin Console RPC must declare status or at least one action');
38
+ const gate = createGenerationAdmissionGate();
39
+ const gates = Object.freeze([gate]);
40
+ return Object.freeze({
41
+ methods: methods,
42
+ get [generationAdmissionSource]() { return gates; },
43
+ });
44
+ }
45
+ function normalizeMethod(name, method, demo, readOnly) {
46
+ if (!method || typeof method.handler !== 'function') {
47
+ throw new TypeError(`Plugin Console RPC ${name} must have a handler`);
48
+ }
49
+ if (method.page !== undefined && (typeof method.page !== 'string' || !pageNamePattern.test(method.page))) {
50
+ throw new TypeError(`Plugin Console RPC ${name} has an invalid page name`);
51
+ }
52
+ return Object.freeze({
53
+ ...(method.page === undefined ? {} : { page: method.page }),
54
+ requiredPermissions: stringList(method.requiredPermissions, `${name} requiredPermissions`),
55
+ requiredRoles: stringList(method.requiredRoles, `${name} requiredRoles`),
56
+ anyOfPermissions: stringList(method.anyOfPermissions, `${name} anyOfPermissions`),
57
+ demo,
58
+ readOnly,
59
+ handler: method.handler,
60
+ });
61
+ }
62
+ function stringList(value, label) {
63
+ if (value === undefined)
64
+ return Object.freeze([]);
65
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim())) {
66
+ throw new TypeError(`Plugin Console RPC ${label} must be non-empty strings`);
67
+ }
68
+ return Object.freeze([...new Set(value)].sort());
69
+ }
@@ -0,0 +1,47 @@
1
+ import type { Dispose } from './dispose.js';
2
+ import type { CapabilityId, PluginId } from './identity.js';
3
+ /** Planned invalidation is distinct from the instances actually replaced at commit. */
4
+ export interface HmrReloadScope {
5
+ readonly fullRoot: boolean;
6
+ readonly plugins: readonly PluginId[];
7
+ readonly capabilities: readonly CapabilityId[];
8
+ }
9
+ export interface HmrEventDetails {
10
+ readonly trigger: 'manual' | 'source';
11
+ readonly changed: readonly string[];
12
+ readonly planned: HmrReloadScope;
13
+ readonly previousGeneration: number;
14
+ readonly activeGeneration: number;
15
+ readonly receiver: Readonly<{
16
+ owner: PluginId;
17
+ creationGeneration: number;
18
+ state: 'current' | 'replaced';
19
+ }>;
20
+ }
21
+ export type HmrOutcome = 'committed' | 'unchanged' | 'rejected' | 'cancelled' | 'restart-required';
22
+ export interface HmrResumeDetails extends HmrEventDetails {
23
+ readonly outcome: HmrOutcome;
24
+ readonly reason?: 'root-stopping';
25
+ readonly replaced: readonly PluginId[];
26
+ }
27
+ /** A control-phase signal, not a command to cancel the plugin's accepted business work. */
28
+ export interface PluginLifecycleEvent<TDetails> {
29
+ readonly signal: AbortSignal;
30
+ readonly details: Readonly<TDetails>;
31
+ }
32
+ export interface PluginLifecycleEventMap {
33
+ readonly 'hmr.pause': PluginLifecycleEvent<HmrEventDetails>;
34
+ readonly 'hmr.resume': PluginLifecycleEvent<HmrResumeDetails>;
35
+ }
36
+ export interface PluginEventSubscriptions {
37
+ /** Subscription belongs to this concrete plugin instance, including when its Scope is reused. */
38
+ on<TName extends keyof PluginLifecycleEventMap>(name: TName, handler: (event: PluginLifecycleEventMap[TName]) => void | Promise<void>): Dispose;
39
+ }
40
+ /** @internal Runtime delivery authority; never exposed through a setup context or resource snapshot. */
41
+ export interface PluginEventChannel {
42
+ readonly events: PluginEventSubscriptions;
43
+ dispatch<TName extends keyof PluginLifecycleEventMap>(name: TName, event: PluginLifecycleEventMap[TName]): Promise<void>;
44
+ dispose(): void;
45
+ }
46
+ /** @internal One instance-owned subscription record; no inherited or process-wide listener registry. */
47
+ export declare function createPluginEventChannel(): PluginEventChannel;
@@ -0,0 +1,30 @@
1
+ /** @internal One instance-owned subscription record; no inherited or process-wide listener registry. */
2
+ export function createPluginEventChannel() {
3
+ const handlers = {
4
+ 'hmr.pause': new Set(), 'hmr.resume': new Set(),
5
+ };
6
+ let disposed = false;
7
+ return Object.freeze({
8
+ events: Object.freeze({
9
+ on(name, handler) {
10
+ if (disposed)
11
+ throw new Error('Plugin event subscriptions are disposed');
12
+ handlers[name].add(handler);
13
+ return () => { handlers[name].delete(handler); };
14
+ },
15
+ }),
16
+ async dispatch(name, event) {
17
+ if (disposed)
18
+ throw new Error('Plugin event subscriptions are disposed');
19
+ const results = await Promise.allSettled([...handlers[name]].map(handler => Promise.resolve().then(() => handler(event))));
20
+ const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
21
+ if (errors.length)
22
+ throw new AggregateError(errors, `Plugin ${name} handlers failed`);
23
+ },
24
+ dispose() {
25
+ disposed = true;
26
+ handlers['hmr.pause'].clear();
27
+ handlers['hmr.resume'].clear();
28
+ },
29
+ });
30
+ }
@@ -1,6 +1,7 @@
1
1
  import type { DeliveryTarget } from './delivery-target.js';
2
2
  import type { JsonValue } from './persistent-state.js';
3
3
  import type { GenerationAdmissionSource } from './admission.js';
4
+ import type { Locale } from './i18n.js';
4
5
  export type PluginTaskState = 'scheduled' | 'paused' | 'running' | 'unknown' | 'succeeded' | 'failed' | 'cancelled';
5
6
  export type PluginTaskAction = 'pause' | 'resume' | 'cancel' | 'update';
6
7
  /** Business task facts. Chat-user tasks are deliberately outside this administrator port. */
@@ -31,19 +32,28 @@ export interface PluginTaskMutationReceipt {
31
32
  readonly operation: 'create' | PluginTaskAction;
32
33
  }
33
34
  export interface PluginTaskMutation {
35
+ /** Display language only; excluded from mutation identity and notification settings. */
36
+ readonly locale?: Locale;
34
37
  /** Client-created operation UUID, retained for reconciliation after a lost response. */
35
38
  readonly requestId: string;
36
39
  readonly expectedRevision: string;
37
40
  }
38
41
  /** Optional plugin-owned task service, resolved from the current generation's owner scope. */
39
42
  export interface PluginTaskProvider extends GenerationAdmissionSource {
43
+ readonly i18n?: Readonly<Partial<Record<Locale, Readonly<{
44
+ title?: string;
45
+ description?: string;
46
+ effects?: readonly string[];
47
+ }>>>>;
40
48
  readonly title: string;
41
49
  readonly description: string;
42
50
  /** JSON Schema for this provider's creation/update parameters, never a Console-specific form. */
43
51
  readonly schema: Readonly<Record<string, JsonValue>>;
44
52
  /** Discloses future external effects before the operator creates a task. */
45
53
  readonly effects: readonly string[];
46
- read(): Promise<PluginTaskSnapshot>;
54
+ read(options?: Readonly<{
55
+ locale?: Locale;
56
+ }>): Promise<PluginTaskSnapshot>;
47
57
  create(input: PluginTaskMutation & Readonly<{
48
58
  target: DeliveryTarget;
49
59
  parameters: Readonly<Record<string, JsonValue>>;
@@ -1,14 +1,19 @@
1
+ import type { Locale } from './i18n.js';
1
2
  /** A deliberate business check. Effects must describe every write or external action. */
2
3
  export interface PluginVerificationCheck {
3
4
  readonly id: string;
4
5
  readonly title: string;
6
+ readonly titleI18n?: Readonly<Partial<Record<Locale, string>>>;
5
7
  readonly effects: readonly string[];
8
+ readonly effectsI18n?: Readonly<Partial<Record<Locale, readonly string[]>>>;
6
9
  readonly scope: 'local' | 'platform';
7
10
  }
8
11
  export interface PluginVerificationOutcome {
9
12
  readonly status: 'passed' | 'failed';
10
13
  readonly summary: string;
14
+ readonly summaryI18n?: Readonly<Partial<Record<Locale, string>>>;
11
15
  readonly evidence: readonly string[];
16
+ readonly evidenceI18n?: Readonly<Partial<Record<Locale, readonly string[]>>>;
12
17
  }
13
18
  /** Plugin-owned optional verification, distinct from generation publication/readiness. */
14
19
  export interface PluginVerificationHost {
package/lib/plugin.d.ts CHANGED
@@ -6,6 +6,7 @@ import type { Dispose, DisposeStack } from './dispose.js';
6
6
  import type { GenerationHandoffRegistry } from './handoff.js';
7
7
  import type { FeatureId, PluginId } from './identity.js';
8
8
  import type { Scope, Token } from './token.js';
9
+ import type { PluginEventSubscriptions } from './plugin-events.js';
9
10
  export interface PluginMetadata {
10
11
  readonly displayName?: string;
11
12
  readonly icon?: string;
@@ -27,6 +28,7 @@ export interface PluginInstanceView {
27
28
  readonly role: 'root' | 'child';
28
29
  }
29
30
  export interface PluginSetupContext<TConfig = unknown> {
31
+ readonly events: PluginEventSubscriptions;
30
32
  readonly signal: AbortSignal;
31
33
  readonly plugin: PluginInstanceView;
32
34
  readonly config: ConfigView<TConfig>;
@@ -40,6 +42,7 @@ export interface PluginSetupContext<TConfig = unknown> {
40
42
  */
41
43
  addFeature<TDefinition>(feature: FeatureId | string, localName: string, definition: TDefinition): void;
42
44
  }
45
+ export { type PluginEventSubscriptions, type PluginLifecycleEvent, type PluginLifecycleEventMap, type HmrEventDetails, type HmrResumeDetails, type HmrReloadScope, type HmrOutcome, } from './plugin-events.js';
43
46
  export interface PluginDefinition<TConfig = unknown> {
44
47
  readonly name: string;
45
48
  readonly config?: PluginConfigSchema<TConfig>;
@@ -69,5 +72,7 @@ export declare function definePlugin<TConfig = unknown>(definition: PluginDefini
69
72
  export { createDeliveryTarget, type DeliveryTarget } from './delivery-target.js';
70
73
  export { PluginStateStore, type JsonValue, type StateScope, type StateRecord, type PersistentStatePort, type StateWriteOptions } from './persistent-state.js';
71
74
  export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostTransaction, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, PluginDatabaseHost, } from './database-host.js';
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';
75
+ export { outboundHostToken, type OutboundConversation, type OutboundEditInput, type OutboundEndpointCapabilities, type OutboundEndpointInput, type OutboundEndpointOperation, type OutboundHost, type OutboundCaptureInput, type CapturedOutboundOperation, type OutboundMessage, type OutboundReactionInput, type OutboundRecallInput, type OutboundRemoveReactionInput, type OutboundSendInput, type OutboundTypingInput, } from './outbound-host.js';
73
76
  export { scheduleHostToken, PluginScheduleHost, type ScheduleJobRegistration, } from './schedule-host.js';
77
+ export { DataMigrationError, pluginDataMigrationProviderToken, type DataMigrationCandidate, type DataMigrationInventory, type DataMigrationRequest, type PrepareDataMigrationRequest, type DataMigrationPlan, type ApplyDataMigrationRequest, type DataMigrationReceipt, type ResumeDataMigrationRequest, type RestoreDataMigrationRequest, type InspectDataMigrationRequest, type DataMigrationInspection, type PluginDataMigrationProvider, } from './data-migration.js';
78
+ export { TargetMigrationScopes, targetMigrationDigest, orderedMigrationRows, createRelationalTargetMigration, type TargetMigrationRows, type TargetMigrationWrite, type RelationalTargetMigrationUnit, type RelationalTargetMigrationOptions, } from './relational-target-migration.js';
package/lib/plugin.js CHANGED
@@ -36,3 +36,5 @@ export { PluginStateStore } from './persistent-state.js';
36
36
  export { databaseHostToken, PluginDatabaseHost, } from './database-host.js';
37
37
  export { outboundHostToken, } from './outbound-host.js';
38
38
  export { scheduleHostToken, PluginScheduleHost, } from './schedule-host.js';
39
+ export { DataMigrationError, pluginDataMigrationProviderToken, } from './data-migration.js';
40
+ export { TargetMigrationScopes, targetMigrationDigest, orderedMigrationRows, createRelationalTargetMigration, } from './relational-target-migration.js';
@@ -8,6 +8,8 @@ export interface RelationalStateCodec {
8
8
  readonly definition: Record<string, unknown>;
9
9
  };
10
10
  readonly tables: Readonly<Record<string, Record<string, unknown>>>;
11
+ /** Explicit additions for an already persisted relational table. */
12
+ readonly addColumns?: Readonly<Record<string, readonly string[]>>;
11
13
  encode(value: JsonValue): {
12
14
  header: Record<string, unknown>;
13
15
  rows: Record<string, readonly Record<string, unknown>[]>;
@@ -22,8 +22,14 @@ export function createRelationalStateStore(database, codecs) {
22
22
  storage_present: { type: 'boolean', nullable: false },
23
23
  storage_expires_at: { type: 'bigint', nullable: true },
24
24
  });
25
- for (const [name, definition] of Object.entries(codec.tables))
26
- database.define(name, definition);
25
+ for (const name of Object.keys(codec.addColumns ?? {})) {
26
+ if (!Object.hasOwn(codec.tables, name))
27
+ throw new TypeError(`Unknown relational migration table: ${name}`);
28
+ }
29
+ for (const [name, definition] of Object.entries(codec.tables)) {
30
+ const columns = codec.addColumns?.[name];
31
+ database.define(name, definition, columns ? { addColumns: columns } : undefined);
32
+ }
27
33
  }
28
34
  const migrated = new Set();
29
35
  const writeRows = async (transaction, codec, rows) => {
@@ -0,0 +1,68 @@
1
+ import type { DatabaseHostTransaction, PluginDatabaseHost } from './database-host.js';
2
+ import { type DeliveryTarget } from './delivery-target.js';
3
+ import { type DataMigrationCandidate, type PluginDataMigrationProvider } from './data-migration.js';
4
+ import type { JsonValue } from './persistent-state.js';
5
+ type Row = Record<string, unknown>;
6
+ /** One business-key row set; identical keyless rows remain an indivisible group. */
7
+ export interface TargetMigrationRows {
8
+ readonly table: string;
9
+ readonly query: Readonly<Row>;
10
+ readonly rows: readonly Row[];
11
+ }
12
+ export interface TargetMigrationWrite {
13
+ readonly before: TargetMigrationRows;
14
+ readonly after: TargetMigrationRows;
15
+ readonly patch: Readonly<Row>;
16
+ }
17
+ export interface RelationalTargetMigrationUnit {
18
+ readonly id: string;
19
+ readonly title: string;
20
+ readonly scope: string;
21
+ readonly status: DataMigrationCandidate['status'];
22
+ readonly records: readonly TargetMigrationRows[];
23
+ readonly summary: Readonly<Record<string, JsonValue>>;
24
+ readonly authorizationCandidates?: readonly JsonValue[];
25
+ }
26
+ export interface RelationalTargetMigrationOptions {
27
+ /** Owner-qualified Host facade; raw process tables never enter the provider. */
28
+ readonly database: PluginDatabaseHost;
29
+ readonly domain: string;
30
+ readonly title: string;
31
+ readonly sourceVersion: string;
32
+ /** All tables that this domain is allowed to read or change. */
33
+ readonly tables: readonly string[];
34
+ readonly scopes: TargetMigrationScopes;
35
+ readUnits(reader: DatabaseHostTransaction): Promise<readonly RelationalTargetMigrationUnit[]>;
36
+ /** Rewrites routing only. Domain state, deadlines, IDs and revision fields are retained. */
37
+ rewrite(unit: RelationalTargetMigrationUnit, target: DeliveryTarget): {
38
+ readonly writes: readonly TargetMigrationWrite[];
39
+ readonly destinationScope: string;
40
+ };
41
+ }
42
+ /** Stable, lossless digest; this does not impose the small-object StateStore size limit. */
43
+ export declare function targetMigrationDigest(value: unknown): string;
44
+ export declare function orderedMigrationRows(rows: readonly Row[]): readonly Row[];
45
+ /** Per-instance scope admission. Existing work drains; unrelated scopes continue. */
46
+ export declare class TargetMigrationScopes {
47
+ #private;
48
+ constructor(input: Readonly<{
49
+ pending: (scope: string) => boolean;
50
+ }>);
51
+ bindInitialization(initialize: () => Promise<void>): void;
52
+ run<T>(input: Readonly<{
53
+ scope: string;
54
+ run: () => Promise<T>;
55
+ }>): Promise<T>;
56
+ pause(input: Readonly<{
57
+ planId: string;
58
+ scopes: readonly string[];
59
+ persist: () => Promise<void>;
60
+ }>): Promise<void>;
61
+ drained(scopes: readonly string[]): boolean;
62
+ maintenance(planId: string, scopes: readonly string[]): 'paused' | 'resumed' | 'unknown';
63
+ restorePause(planId: string, scopes: readonly string[]): void;
64
+ resume(planId: string, scopes: readonly string[]): void;
65
+ }
66
+ /** Shared only by the concrete game and subscription target migrations. No raw-SQL or generic schema discovery. */
67
+ export declare function createRelationalTargetMigration(options: RelationalTargetMigrationOptions): PluginDataMigrationProvider;
68
+ export {};