@zhin.js/plugin-runtime 1.1.16 → 1.1.17

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.
@@ -44,6 +44,11 @@ export interface DatabaseHostTable {
44
44
  readonly name: string;
45
45
  readonly columns?: Readonly<Record<string, unknown>>;
46
46
  }
47
+ /** Explicit, additive upgrades applied before SQL models become available. */
48
+ export interface DatabaseTableOptions {
49
+ /** Names from this table's definition. Existing columns and data remain untouched. */
50
+ readonly addColumns?: readonly string[];
51
+ }
47
52
  export interface DatabaseHostSelectResult {
48
53
  readonly rows: readonly unknown[];
49
54
  readonly total: number;
@@ -83,7 +88,7 @@ export interface DatabaseHost {
83
88
  readonly dialect: string;
84
89
  readonly started: boolean;
85
90
  /** Register a table schema before `start` (column defs match `@zhin.js/database`). */
86
- define(name: string, definition: Record<string, unknown>): void;
91
+ define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
87
92
  /** Names of tables registered via `define` (Console `db:tables`). */
88
93
  tables(): readonly string[];
89
94
  models: {
@@ -129,7 +134,7 @@ export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
129
134
  get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
130
135
  get dialect(): string;
131
136
  get started(): boolean;
132
- define(name: string, definition: Record<string, unknown>): void;
137
+ define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
133
138
  tables(): readonly string[];
134
139
  transaction<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
135
140
  }
@@ -54,8 +54,10 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
54
54
  get started() {
55
55
  return this.#host.started;
56
56
  }
57
- define(name, definition) {
58
- this.#operation.setup(() => this.#host.define(this.qualify(name), definition));
57
+ define(name, definition, options) {
58
+ this.#operation.setup(() => options === undefined
59
+ ? this.#host.define(this.qualify(name), definition)
60
+ : this.#host.define(this.qualify(name), definition, options));
59
61
  }
60
62
  tables() {
61
63
  return this.#operation.setup(() => Object.freeze(this.#host.tables().flatMap((name) => {
package/lib/i18n.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { DeliveryTarget } from './delivery-target.js';
2
+ /** Supported display languages. Time zone and command identities are independent. */
3
+ export type Locale = 'zh-CN' | 'en';
4
+ export declare const DEFAULT_LOCALE: Locale;
5
+ export declare const SUPPORTED_LOCALES: readonly ["zh-CN", "en"];
6
+ export declare function isLocale(value: unknown): value is Locale;
7
+ /** Resolve an absent display preference; configuration writes validate with isLocale. */
8
+ export declare function resolveLocale(value: unknown): Locale;
9
+ /** Immutable project language, projected with the operation's generation. */
10
+ export declare const projectLocaleToken: import("./token.js").Token<Locale>;
11
+ /** Root-owned preferences; identity never comes from display names or profile text. */
12
+ export interface UserLocalePreferences {
13
+ get(endpoint: DeliveryTarget['endpoint'], senderId: string): Promise<Locale | undefined>;
14
+ set(endpoint: DeliveryTarget['endpoint'], senderId: string, locale: Locale): Promise<void>;
15
+ }
16
+ export declare const userLocalePreferencesToken: import("./token.js").Token<UserLocalePreferences>;
17
+ export type MessageCatalog = Readonly<Record<string, Readonly<Record<Locale, string>>>>;
18
+ export type MessageValues = Readonly<Record<string, string | number>>;
19
+ /**
20
+ * A package-owned, complete bilingual catalog. No global language or registration.
21
+ * Placeholder substitution is one pass: user text cannot become another template.
22
+ * Values remain plain text; HTML/JSX escaping belongs to the existing renderer.
23
+ */
24
+ export declare function createMessages<const T extends MessageCatalog>(catalog: T): (locale: Locale | undefined, key: keyof T & string, values?: MessageValues) => string;
package/lib/i18n.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createToken } from './token.js';
2
+ export const DEFAULT_LOCALE = 'zh-CN';
3
+ export const SUPPORTED_LOCALES = Object.freeze(['zh-CN', 'en']);
4
+ export function isLocale(value) {
5
+ return value === 'zh-CN' || value === 'en';
6
+ }
7
+ /** Resolve an absent display preference; configuration writes validate with isLocale. */
8
+ export function resolveLocale(value) {
9
+ return isLocale(value) ? value : DEFAULT_LOCALE;
10
+ }
11
+ /** Immutable project language, projected with the operation's generation. */
12
+ export const projectLocaleToken = createToken('zhin.project-locale');
13
+ export const userLocalePreferencesToken = createToken('zhin.user-locale-preferences');
14
+ /**
15
+ * A package-owned, complete bilingual catalog. No global language or registration.
16
+ * Placeholder substitution is one pass: user text cannot become another template.
17
+ * Values remain plain text; HTML/JSX escaping belongs to the existing renderer.
18
+ */
19
+ export function createMessages(catalog) {
20
+ const entries = Object.freeze(Object.fromEntries(Object.entries(catalog).map(([key, value]) => {
21
+ const keys = (text) => [...new Set([...text.matchAll(/\{([a-zA-Z][\w]*)\}/gu)].map(match => match[1]))].sort();
22
+ if (typeof value['zh-CN'] !== 'string' || typeof value.en !== 'string'
23
+ || JSON.stringify(keys(value['zh-CN'])) !== JSON.stringify(keys(value.en))) {
24
+ throw new TypeError(`Invalid bilingual message catalog entry: ${key}`);
25
+ }
26
+ return [key, Object.freeze({ ...value })];
27
+ })));
28
+ return (locale, key, values = {}) => {
29
+ const entry = entries[key];
30
+ if (!Object.hasOwn(entries, key) || !entry)
31
+ throw new TypeError(`Unknown message catalog entry: ${key}`);
32
+ return entry[resolveLocale(locale)].replace(/\{([a-zA-Z][\w]*)\}/gu, (_match, name) => {
33
+ if (!Object.hasOwn(values, name))
34
+ throw new TypeError(`Missing message value: ${name}`);
35
+ return String(values[name]);
36
+ });
37
+ };
38
+ }
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './handoff.js';
12
12
  export * from './plugin.js';
13
13
  /** @public Root configuration contract shared by Runtime and composition tooling. */
14
14
  export * from './plugin-configuration.js';
15
+ export * from './i18n.js';
15
16
  export * from './root-config-file.js';
16
17
  export * from './root-config-document.js';
17
18
  /** @internal Runtime 内部:RootRuntime / RootController。 */
@@ -39,3 +40,4 @@ export * from './system-log.js';
39
40
  export * from './plugin-verification.js';
40
41
  export * from './plugin-tasks.js';
41
42
  export * from './task-storage.js';
43
+ export * from './plugin-console-rpc.js';
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ export * from './handoff.js';
12
12
  export * from './plugin.js';
13
13
  /** @public Root configuration contract shared by Runtime and composition tooling. */
14
14
  export * from './plugin-configuration.js';
15
+ export * from './i18n.js';
15
16
  export * from './root-config-file.js';
16
17
  export * from './root-config-document.js';
17
18
  /** @internal Runtime 内部:RootRuntime / RootController。 */
@@ -39,3 +40,4 @@ export * from './system-log.js';
39
40
  export * from './plugin-verification.js';
40
41
  export * from './plugin-tasks.js';
41
42
  export * from './task-storage.js';
43
+ export * from './plugin-console-rpc.js';
@@ -1,5 +1,6 @@
1
1
  import type { DeliveryTarget } from './delivery-target.js';
2
2
  import type { PluginId } from './identity.js';
3
+ import type { Locale } from './i18n.js';
3
4
  import { generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionGate } from './admission.js';
4
5
  /**
5
6
  * 未锚定 endpoint 的会话地址(结构对齐 `@zhin.js/im-contract` 的
@@ -21,6 +22,8 @@ export interface OutboundConversation {
21
22
  * `sendEndpointMessage` (resolve adapter/endpoint → AdapterIndex.send).
22
23
  */
23
24
  export interface OutboundSendInput {
25
+ /** Language captured by the originating operation or persisted task. */
26
+ readonly locale?: Locale;
24
27
  readonly adapter: string;
25
28
  readonly endpointKey: string;
26
29
  readonly conversation: OutboundConversation;
@@ -73,7 +76,9 @@ export interface OutboundMessage {
73
76
  }
74
77
  export interface OutboundHost {
75
78
  /** Sends to persisted exact identities, rejecting stale addresses instead of aliasing another bot. */
76
- sendTarget?(target: DeliveryTarget, content: unknown): Promise<string | null>;
79
+ sendTarget?(target: DeliveryTarget, content: unknown, options?: Readonly<{
80
+ locale?: Locale;
81
+ }>): Promise<string | null>;
77
82
  /** Runs related operations against one IM Runtime snapshot captured at ingress. */
78
83
  runWithView?<T>(operation: () => Promise<T>): Promise<T>;
79
84
  /** Exact operations declared by one live Endpoint. */
@@ -95,7 +100,9 @@ export interface OutboundHost {
95
100
  }
96
101
  /** Root-only process port: requester is supplied by the owner-bound facade. */
97
102
  export interface OutboundRootHost extends Omit<OutboundHost, 'sendTarget'> {
98
- sendTarget?(target: DeliveryTarget, content: unknown, requester: PluginId): Promise<string | null>;
103
+ sendTarget?(target: DeliveryTarget, content: unknown, requester: PluginId, options?: Readonly<{
104
+ locale?: Locale;
105
+ }>): Promise<string | null>;
99
106
  }
100
107
  /** Preserve the sending plugin as requester, independent of the destination endpoint owner. */
101
108
  export declare class PluginOutboundHost implements OutboundHost {
@@ -28,7 +28,9 @@ export class PluginOutboundHost {
28
28
  const recall = host.recall?.bind(host);
29
29
  const edit = host.edit?.bind(host);
30
30
  const typing = host.typing?.bind(host);
31
- this.sendTarget = sendTarget ? async (target, content) => this.#run(() => sendTarget(target, content, this.#owner)) : undefined;
31
+ this.sendTarget = sendTarget ? async (target, content, options) => this.#run(() => options
32
+ ? sendTarget(target, content, this.#owner, options)
33
+ : sendTarget(target, content, this.#owner)) : undefined;
32
34
  this.runWithView = runWithView ? (operation) => this.#run(() => runWithView(operation)) : undefined;
33
35
  this.capabilities = capabilities ? (input) => this.#run(() => capabilities(input)) : undefined;
34
36
  this.addReaction = addReaction ? (input) => this.#run(() => addReaction(input)) : undefined;
@@ -0,0 +1,86 @@
1
+ import { type GenerationAdmissionSource } from './admission.js';
2
+ import type { JsonValue } from './persistent-state.js';
3
+ /**
4
+ * Plugin-registered Console RPC (zhinjs/zhin#970).
5
+ *
6
+ * A plugin publishes one {@link PluginConsoleRpc} through {@link pluginConsoleRpcToken}. The Host
7
+ * exposes it on the existing Console RPC envelope as `plugin.<pluginId>.status` and
8
+ * `plugin.<pluginId>.<action>`, where `<pluginId>` is the owner PluginId (for example
9
+ * `root/typesafe`). No WebSocket, no new HTTP route, and no generic config writes.
10
+ *
11
+ * Authorization follows the Console Page the method is bound to (`page`): the caller must
12
+ * satisfy that page's `requiredPermissions` / `requiredRoles`, plus the method's own. The full
13
+ * (admin) token may call everything. Other tokens (`user` / `demo`) may call a method when they
14
+ * hold every required permission and role. An action is open to them only when it declares its
15
+ * own `requiredPermissions` / `requiredRoles` / `anyOfPermissions` (the page's requirements apply on
16
+ * top, but never grant an action by themselves); an unguarded `status` needs `demo: true`.
17
+ * `anyOfPermissions` is satisfied by holding at least one of the listed permissions (for a new
18
+ * permission that coexists with an older one, e.g. `ops:approve` beside `tool_approvals:resolve`).
19
+ */
20
+ export type PluginConsoleRpcJson = JsonValue;
21
+ export type PluginConsoleRpcInput = Readonly<Record<string, JsonValue>>;
22
+ export interface PluginConsoleRpcCall {
23
+ /** Aborted when the Host request times out. Long work must observe it. */
24
+ readonly signal: AbortSignal;
25
+ }
26
+ export interface PluginConsoleRpcMethod<TInput = PluginConsoleRpcInput> {
27
+ /** Local name of a Page owned by the same plugin (`pages/<page>/index.tsx`). Its access rules apply. */
28
+ readonly page?: string;
29
+ /** Extra permissions on top of the page's own requirements. */
30
+ readonly requiredPermissions?: readonly string[];
31
+ /** Extra roles on top of the page's own requirements. */
32
+ readonly requiredRoles?: readonly string[];
33
+ /**
34
+ * The caller must also hold at least one of these (in addition to every `requiredPermissions`).
35
+ * Counts as the method's own requirement for actions.
36
+ */
37
+ readonly anyOfPermissions?: readonly string[];
38
+ /**
39
+ * Return a small, user-facing JSON shape. Never return raw log records, prompts, chat lines,
40
+ * secrets or exception text: the Console shows this to ordinary users.
41
+ */
42
+ handler(input: TInput, call: PluginConsoleRpcCall): JsonValue | Promise<JsonValue>;
43
+ }
44
+ export interface PluginConsoleRpcStatus extends PluginConsoleRpcMethod<PluginConsoleRpcInput> {
45
+ /** Allow the demo-scope Console token to read this status. Default `false`. */
46
+ readonly demo?: boolean;
47
+ }
48
+ export interface PluginConsoleRpcDefinition {
49
+ /** Read-only status for the plugin's Console page(s): `plugin.<pluginId>.status`. */
50
+ readonly status?: PluginConsoleRpcStatus;
51
+ /**
52
+ * Extra read-only views beside `status`: `plugin.<pluginId>.<name>`. Same access rules as
53
+ * `status`; use them when one plugin backs several pages with different permissions.
54
+ */
55
+ readonly queries?: Readonly<Record<string, PluginConsoleRpcStatus>>;
56
+ /**
57
+ * Limited, named actions: `plugin.<pluginId>.<name>`. Non-admin tokens may run one only when the
58
+ * action declares its own requirements and the token holds them (plus the page's); otherwise
59
+ * admin-only.
60
+ */
61
+ readonly actions?: Readonly<Record<string, PluginConsoleRpcMethod>>;
62
+ }
63
+ export interface NormalizedPluginConsoleRpcMethod {
64
+ readonly page?: string;
65
+ readonly requiredPermissions: readonly string[];
66
+ readonly requiredRoles: readonly string[];
67
+ /** Empty when the method has no either-of requirement. */
68
+ readonly anyOfPermissions: readonly string[];
69
+ readonly demo: boolean;
70
+ /** `status` and `queries` are read-only; actions are not. */
71
+ readonly readOnly: boolean;
72
+ readonly handler: PluginConsoleRpcMethod['handler'];
73
+ }
74
+ /** Value published under {@link pluginConsoleRpcToken}; create it with {@link definePluginConsoleRpc}. */
75
+ export interface PluginConsoleRpc extends GenerationAdmissionSource {
76
+ readonly methods: ReadonlyMap<string, NormalizedPluginConsoleRpcMethod>;
77
+ }
78
+ /** Safe, user-facing failure. `message` is shown in the Console; never put raw errors in it. */
79
+ export declare class PluginConsoleRpcError extends Error {
80
+ readonly code: 'invalid_input' | 'conflict' | 'not_found' | 'unsupported' | 'unavailable';
81
+ constructor(code: PluginConsoleRpcError['code'], message: string);
82
+ }
83
+ export declare const pluginConsoleRpcToken: import("./token.js").Token<PluginConsoleRpc>;
84
+ export declare const PLUGIN_CONSOLE_RPC_STATUS = "status";
85
+ /** Validates and freezes a plugin's Console RPC surface. Throws on invalid names at setup time. */
86
+ export declare function definePluginConsoleRpc(definition: PluginConsoleRpcDefinition): PluginConsoleRpc;
@@ -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
+ }
@@ -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 {
@@ -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) => {
@@ -36,6 +36,10 @@ function setValue(document, path, value) {
36
36
  for (const [index, segment] of path.slice(0, -1).entries()) {
37
37
  const existing = readChild(target, segment, path.slice(0, index + 1));
38
38
  if (existing === undefined) {
39
+ // Inline guard (in addition to assertPath) so the write is visibly safe.
40
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
41
+ throw unsafeSegment(segment);
42
+ }
39
43
  const created = {};
40
44
  target[segment] = created;
41
45
  target = created;
@@ -59,7 +63,11 @@ function removeValue(document, path) {
59
63
  target.splice(arrayIndex(segment, target, path), 1);
60
64
  return;
61
65
  }
62
- delete target[segment];
66
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
67
+ throw unsafeSegment(segment);
68
+ }
69
+ if (Object.hasOwn(target, segment))
70
+ delete target[segment];
63
71
  }
64
72
  function cloneDocument(value) {
65
73
  const document = structuredClone(value);
@@ -71,13 +79,18 @@ function cloneDocument(value) {
71
79
  function readChild(container, segment, path) {
72
80
  if (Array.isArray(container))
73
81
  return container[arrayIndex(segment, container, path)];
74
- return container[segment];
82
+ // Only own properties count: inherited members (toString, hasOwnProperty, ...)
83
+ // must never be traversed into or treated as existing config values.
84
+ return Object.hasOwn(container, segment) ? container[segment] : undefined;
75
85
  }
76
86
  function writeChild(container, segment, value, path) {
77
87
  if (Array.isArray(container)) {
78
88
  container[arrayIndex(segment, container, path)] = value;
79
89
  return;
80
90
  }
91
+ if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
92
+ throw unsafeSegment(segment);
93
+ }
81
94
  container[segment] = value;
82
95
  }
83
96
  function arrayIndex(segment, container, path) {
@@ -99,10 +112,13 @@ function requireContainer(value, path) {
99
112
  function assertPath(path) {
100
113
  for (const segment of path) {
101
114
  if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
102
- throw new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
115
+ throw unsafeSegment(segment);
103
116
  }
104
117
  }
105
118
  }
119
+ function unsafeSegment(segment) {
120
+ return new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
121
+ }
106
122
  function lastSegment(path) {
107
123
  const segment = path[path.length - 1];
108
124
  if (!segment)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-runtime",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",