@zhin.js/plugin-runtime 1.1.15 → 1.1.16

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.
@@ -35,6 +35,10 @@ export interface DatabaseHostModel {
35
35
  */
36
36
  count?(where?: Record<string, unknown>): Promise<number>;
37
37
  }
38
+ /** Callback-scoped transaction models; no raw SQL or administration access. */
39
+ export interface DatabaseHostTransaction {
40
+ model(name: string): DatabaseHostModel;
41
+ }
38
42
  export type DatabaseHostType = 'related' | 'document' | 'keyvalue';
39
43
  export interface DatabaseHostTable {
40
44
  readonly name: string;
@@ -85,6 +89,8 @@ export interface DatabaseHost {
85
89
  models: {
86
90
  get(name: string): DatabaseHostModel | undefined;
87
91
  };
92
+ /** Atomic SQL operations. The callback must await all operations before returning. */
93
+ transaction?<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
88
94
  /** Root-only management port; absent on lightweight test/memory hosts. */
89
95
  readonly console?: DatabaseHostConsole;
90
96
  /**
@@ -125,7 +131,10 @@ export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
125
131
  get started(): boolean;
126
132
  define(name: string, definition: Record<string, unknown>): void;
127
133
  tables(): readonly string[];
134
+ transaction<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
128
135
  }
136
+ /** A saved query/model cannot escape the callback and reuse the transaction. */
137
+ export declare function guardTransactionModel(model: DatabaseHostModel, check: () => void): DatabaseHostModel;
129
138
  /**
130
139
  * Plugin-facing scoped persistence token. Runtime replaces this binding for
131
140
  * every Plugin Scope; plugin code must never receive the process-wide host.
@@ -63,6 +63,27 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
63
63
  return logical === undefined ? [] : [logical];
64
64
  })));
65
65
  }
66
+ async transaction(callback) {
67
+ if (!this.#host.transaction)
68
+ throw new Error('Database Host does not support transactions');
69
+ return this.#execute(() => this.#host.transaction(async (transaction) => {
70
+ let active = true;
71
+ const requireActive = () => { if (!active)
72
+ throw new Error('Database transaction is already closed'); };
73
+ try {
74
+ return await callback(Object.freeze({ model: (name) => {
75
+ requireActive();
76
+ // Reuse the ordinary facade so qualification and generation checks stay identical.
77
+ const model = transaction.model(this.qualify(name));
78
+ const guarded = guardTransactionModel(model, requireActive);
79
+ return this.#bindModel(guarded);
80
+ } }));
81
+ }
82
+ finally {
83
+ active = false;
84
+ }
85
+ }));
86
+ }
66
87
  #bindSelection(selection) {
67
88
  const modify = (operation) => this.#operation.setup(() => {
68
89
  const next = operation();
@@ -94,6 +115,21 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
94
115
  return this.#operation.run(operation);
95
116
  }
96
117
  }
118
+ /** A saved query/model cannot escape the callback and reuse the transaction. */
119
+ export function guardTransactionModel(model, check) {
120
+ const selection = (source) => ({
121
+ where(query) { check(); return selection(source.where(query)); },
122
+ orderBy(field, direction) { check(); return selection(source.orderBy(field, direction)); },
123
+ limit(count) { check(); return selection(source.limit(count)); },
124
+ then(resolve, reject) { return Promise.resolve().then(() => { check(); return source; }).then(resolve, reject); },
125
+ });
126
+ return {
127
+ select: (...fields) => { check(); return selection(model.select(...fields)); },
128
+ insert: async (row) => { check(); return model.insert(row); },
129
+ update: patch => { check(); return { where: async (query) => { check(); return model.update(patch).where(query); } }; },
130
+ delete: () => { check(); return { where: async (query) => { check(); return model.delete().where(query); } }; },
131
+ };
132
+ }
97
133
  /**
98
134
  * Plugin-facing scoped persistence token. Runtime replaces this binding for
99
135
  * every Plugin Scope; plugin code must never receive the process-wide host.
package/lib/inbox.d.ts CHANGED
@@ -10,6 +10,28 @@ import type { DatabaseHost } from './database-host.js';
10
10
  export declare const INBOX_TABLE_MESSAGE = "unified_inbox_message";
11
11
  export declare const INBOX_TABLE_REQUEST = "unified_inbox_request";
12
12
  export declare const INBOX_TABLE_NOTICE = "unified_inbox_notice";
13
+ export declare const INBOX_MESSAGE_QUERY_COLUMNS: {
14
+ readonly direction: {
15
+ readonly type: "text";
16
+ readonly nullable: true;
17
+ };
18
+ readonly endpoint_capability_id: {
19
+ readonly type: "text";
20
+ readonly nullable: true;
21
+ };
22
+ readonly endpoint_adapter_id: {
23
+ readonly type: "text";
24
+ readonly nullable: true;
25
+ };
26
+ readonly thread_id: {
27
+ readonly type: "text";
28
+ readonly nullable: true;
29
+ };
30
+ readonly query_version: {
31
+ readonly type: "integer";
32
+ readonly nullable: true;
33
+ };
34
+ };
13
35
  export declare const INBOX_MESSAGE_DEFINITION: Record<string, unknown>;
14
36
  export declare const INBOX_REQUEST_DEFINITION: Record<string, unknown>;
15
37
  export declare const INBOX_NOTICE_DEFINITION: Record<string, unknown>;
package/lib/inbox.js CHANGED
@@ -10,7 +10,15 @@ import { createToken } from './token.js';
10
10
  export const INBOX_TABLE_MESSAGE = 'unified_inbox_message';
11
11
  export const INBOX_TABLE_REQUEST = 'unified_inbox_request';
12
12
  export const INBOX_TABLE_NOTICE = 'unified_inbox_notice';
13
+ export const INBOX_MESSAGE_QUERY_COLUMNS = {
14
+ direction: { type: 'text', nullable: true },
15
+ endpoint_capability_id: { type: 'text', nullable: true },
16
+ endpoint_adapter_id: { type: 'text', nullable: true },
17
+ thread_id: { type: 'text', nullable: true },
18
+ query_version: { type: 'integer', nullable: true },
19
+ };
13
20
  export const INBOX_MESSAGE_DEFINITION = {
21
+ ...INBOX_MESSAGE_QUERY_COLUMNS,
14
22
  id: { type: 'integer', primary: true, autoIncrement: true },
15
23
  adapter: { type: 'text', nullable: false },
16
24
  endpoint_id: { type: 'text', nullable: false },
package/lib/index.d.ts CHANGED
@@ -33,7 +33,9 @@ export * from './outbound-host.js';
33
33
  export * from './outbound-sender.js';
34
34
  export * from './delivery-target.js';
35
35
  export * from './persistent-state.js';
36
+ export * from './relational-state.js';
36
37
  export * from './inbox.js';
37
38
  export * from './system-log.js';
38
39
  export * from './plugin-verification.js';
39
40
  export * from './plugin-tasks.js';
41
+ export * from './task-storage.js';
package/lib/index.js CHANGED
@@ -33,7 +33,9 @@ export * from './outbound-host.js';
33
33
  export * from './outbound-sender.js';
34
34
  export * from './delivery-target.js';
35
35
  export * from './persistent-state.js';
36
+ export * from './relational-state.js';
36
37
  export * from './inbox.js';
37
38
  export * from './system-log.js';
38
39
  export * from './plugin-verification.js';
39
40
  export * from './plugin-tasks.js';
41
+ export * from './task-storage.js';
package/lib/plugin.d.ts CHANGED
@@ -68,6 +68,6 @@ export interface PluginDefinition<TConfig = unknown> {
68
68
  export declare function definePlugin<TConfig = unknown>(definition: PluginDefinition<TConfig>): Readonly<PluginDefinition<TConfig>>;
69
69
  export { createDeliveryTarget, type DeliveryTarget } from './delivery-target.js';
70
70
  export { PluginStateStore, type JsonValue, type StateScope, type StateRecord, type PersistentStatePort, type StateWriteOptions } from './persistent-state.js';
71
- export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, PluginDatabaseHost, } from './database-host.js';
71
+ export { databaseHostToken, type DatabaseHostConsole, type DatabaseHostModel, type DatabaseHostTransaction, type DatabaseHostSelection, type DatabaseHostSelectResult, type DatabaseHostTable, type DatabaseHostType, PluginDatabaseHost, } from './database-host.js';
72
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';
73
73
  export { scheduleHostToken, PluginScheduleHost, type ScheduleJobRegistration, } from './schedule-host.js';
@@ -0,0 +1,24 @@
1
+ import { PluginStateStore, type JsonValue } from './persistent-state.js';
2
+ import type { PluginDatabaseHost } from './database-host.js';
3
+ export interface RelationalStateCodec {
4
+ /** Existing plugin-scoped key to migrate once. */
5
+ readonly key: string;
6
+ readonly header: {
7
+ readonly table: string;
8
+ readonly definition: Record<string, unknown>;
9
+ };
10
+ readonly tables: Readonly<Record<string, Record<string, unknown>>>;
11
+ encode(value: JsonValue): {
12
+ header: Record<string, unknown>;
13
+ rows: Record<string, readonly Record<string, unknown>[]>;
14
+ };
15
+ decode(header: Record<string, unknown>, rows: Record<string, readonly Record<string, unknown>[]>): JsonValue;
16
+ }
17
+ /**
18
+ * Reuses PluginStateStore's CAS and admission contract with relational records.
19
+ * Only registered plugin keys are accepted; ordinary state uses database.state.
20
+ * Register in setup. Tables, revision and receipts commit or roll back together.
21
+ */
22
+ export declare function createRelationalStateStore(database: PluginDatabaseHost, codecs: readonly RelationalStateCodec[]): PluginStateStore;
23
+ /** Only for pure database mutations; never wrap external sends or arbitrary side effects. */
24
+ export declare function retryRelationalOperation<T>(operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1,124 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setImmediate } from 'node:timers/promises';
3
+ import { PluginStateStore } from './persistent-state.js';
4
+ /**
5
+ * Reuses PluginStateStore's CAS and admission contract with relational records.
6
+ * Only registered plugin keys are accepted; ordinary state uses database.state.
7
+ * Register in setup. Tables, revision and receipts commit or roll back together.
8
+ */
9
+ export function createRelationalStateStore(database, codecs) {
10
+ if (!['sqlite', 'mysql', 'pg'].includes(database.dialect)) {
11
+ throw new Error(`结构化任务持久化仅支持 SQLite、MySQL、PostgreSQL,当前为 ${database.dialect};不会自动更换数据库`);
12
+ }
13
+ const legacy = database.state;
14
+ const byKey = new Map(codecs.map(codec => [codec.key, codec]));
15
+ if (byKey.size !== codecs.length)
16
+ throw new Error('Duplicate relational state key');
17
+ for (const codec of codecs) {
18
+ database.define(codec.header.table, {
19
+ ...codec.header.definition,
20
+ storage_id: { type: 'integer', primary: true },
21
+ storage_revision: { type: 'text', length: 36, nullable: false },
22
+ storage_present: { type: 'boolean', nullable: false },
23
+ storage_expires_at: { type: 'bigint', nullable: true },
24
+ });
25
+ for (const [name, definition] of Object.entries(codec.tables))
26
+ database.define(name, definition);
27
+ }
28
+ const migrated = new Set();
29
+ const writeRows = async (transaction, codec, rows) => {
30
+ for (const name of Object.keys(codec.tables)) {
31
+ const model = transaction.model(name);
32
+ await model.delete().where({});
33
+ for (const row of rows[name] ?? [])
34
+ await model.insert(row);
35
+ }
36
+ };
37
+ const migrate = async (codec) => {
38
+ if (migrated.has(codec.key))
39
+ return;
40
+ // Read outside the transaction: the old port may use another connection.
41
+ const old = await legacy.get(codec.key);
42
+ await retryRelationalOperation(() => database.transaction(async (transaction) => {
43
+ const model = transaction.model(codec.header.table);
44
+ if ((await model.select().where({ storage_id: 1 }))[0])
45
+ return;
46
+ const encoded = old === undefined ? { header: {}, rows: {} } : codec.encode(old);
47
+ await model.insert({ ...encoded.header, storage_id: 1, storage_revision: randomUUID(),
48
+ storage_present: old !== undefined, storage_expires_at: null });
49
+ await writeRows(transaction, codec, encoded.rows);
50
+ }));
51
+ // A durable header is the cutover marker, including an empty/deleted book.
52
+ // Cleanup failure is safe to retry; never reimport an old deleted task.
53
+ await retryRelationalOperation(() => legacy.delete(codec.key));
54
+ migrated.add(codec.key);
55
+ };
56
+ return new PluginStateStore(database.owner, {
57
+ async read(_owner, scope, key) {
58
+ const codec = byKey.get(key);
59
+ if (!codec || scope.kind !== 'plugin')
60
+ throw new Error(`Unregistered relational state key: ${key}`);
61
+ await migrate(codec);
62
+ return retryRelationalOperation(() => database.transaction(async (transaction) => {
63
+ const header = (await transaction.model(codec.header.table).select().where({ storage_id: 1 }))[0];
64
+ if (!header)
65
+ throw new Error('Relational state header is missing');
66
+ const rows = {};
67
+ for (const name of Object.keys(codec.tables))
68
+ rows[name] = await transaction.model(name).select().orderBy('position');
69
+ return { revision: String(header.storage_revision),
70
+ value: header.storage_present ? codec.decode(header, rows) : null,
71
+ // Tombstones must remain visible to CAS, but appear absent to the updater.
72
+ expiresAt: header.storage_present ? (header.storage_expires_at == null ? null : Number(header.storage_expires_at)) : 0 };
73
+ }));
74
+ },
75
+ async compareExchange(_owner, scope, key, revision, value, expiresAt) {
76
+ const codec = byKey.get(key);
77
+ if (!codec || scope.kind !== 'plugin')
78
+ throw new Error(`Unregistered relational state key: ${key}`);
79
+ if (revision === undefined)
80
+ throw new Error('Relational state must be read before CAS');
81
+ try {
82
+ return await database.transaction(async (transaction) => {
83
+ const encoded = value === undefined ? { header: {}, rows: {} } : codec.encode(value);
84
+ const changed = await transaction.model(codec.header.table).update({ ...encoded.header,
85
+ storage_revision: randomUUID(), storage_present: value !== undefined, storage_expires_at: expiresAt,
86
+ }).where({ storage_id: 1, storage_revision: revision });
87
+ if (changed !== 1)
88
+ return false;
89
+ await writeRows(transaction, codec, encoded.rows);
90
+ return true;
91
+ });
92
+ }
93
+ catch (error) {
94
+ if (!isRelationalContention(error))
95
+ throw error;
96
+ await setImmediate();
97
+ return false;
98
+ }
99
+ },
100
+ });
101
+ }
102
+ /** Only for pure database mutations; never wrap external sends or arbitrary side effects. */
103
+ export async function retryRelationalOperation(operation) {
104
+ for (let attempt = 0;; attempt++) {
105
+ try {
106
+ return await operation();
107
+ }
108
+ catch (error) {
109
+ if (attempt >= 63 || !isRelationalContention(error))
110
+ throw error;
111
+ await setImmediate();
112
+ }
113
+ }
114
+ }
115
+ /** Constraint races and serialization conflicts may replay the pure updater. */
116
+ function isRelationalContention(error) {
117
+ if (!error || typeof error !== 'object')
118
+ return false;
119
+ const value = error;
120
+ return ['40001', '40P01', '23505', 'SQLITE_BUSY', 'SQLITE_BUSY_SNAPSHOT', 'SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_UNIQUE', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'].includes(value.code ?? '')
121
+ || [1062, 1213, 1205].includes(value.errno ?? 0)
122
+ || [5, 517, 1555, 2067].includes(value.errcode ?? 0)
123
+ || (value.cause !== undefined && isRelationalContention(value.cause));
124
+ }
@@ -0,0 +1,74 @@
1
+ import { type DeliveryTarget } from './delivery-target.js';
2
+ /** Reversible SQL text encoding, including endpoint ids containing NUL. */
3
+ export declare function encodeStorageIdentity(value: string): string;
4
+ export declare function decodeStorageIdentity(value: unknown): string;
5
+ export declare const TASK_TARGET_COLUMNS: {
6
+ readonly endpoint_adapter: {
7
+ readonly type: "text";
8
+ readonly nullable: false;
9
+ };
10
+ readonly endpoint_id: {
11
+ readonly type: "text";
12
+ readonly nullable: false;
13
+ };
14
+ readonly conversation_kind: {
15
+ readonly type: "text";
16
+ readonly nullable: false;
17
+ };
18
+ readonly conversation_id: {
19
+ readonly type: "text";
20
+ readonly nullable: false;
21
+ };
22
+ readonly parent_kind: {
23
+ readonly type: "text";
24
+ readonly nullable: true;
25
+ };
26
+ readonly parent_id: {
27
+ readonly type: "text";
28
+ readonly nullable: true;
29
+ };
30
+ readonly thread_id: {
31
+ readonly type: "text";
32
+ readonly nullable: true;
33
+ };
34
+ };
35
+ export declare function encodeTaskTarget(target: DeliveryTarget): Record<string, unknown>;
36
+ export declare function decodeTaskTarget(row: Record<string, unknown>): DeliveryTarget;
37
+ export declare const TASK_RECEIPT_COLUMNS: {
38
+ readonly id: {
39
+ readonly type: "text";
40
+ readonly length: 128;
41
+ readonly primary: true;
42
+ };
43
+ readonly position: {
44
+ readonly type: "integer";
45
+ readonly nullable: false;
46
+ };
47
+ readonly fingerprint: {
48
+ readonly type: "text";
49
+ readonly length: 64;
50
+ readonly nullable: false;
51
+ };
52
+ readonly task_id: {
53
+ readonly type: "text";
54
+ readonly length: 128;
55
+ readonly nullable: false;
56
+ };
57
+ readonly operation: {
58
+ readonly type: "text";
59
+ readonly length: 32;
60
+ readonly nullable: false;
61
+ };
62
+ };
63
+ export declare function encodeTaskReceipts(receipts: readonly {
64
+ id: string;
65
+ fingerprint: string;
66
+ taskId: string;
67
+ operation: string;
68
+ }[]): Record<string, unknown>[];
69
+ export declare function decodeTaskReceipts(rows: readonly Record<string, unknown>[]): {
70
+ id: string;
71
+ fingerprint: string;
72
+ taskId: string;
73
+ operation: string;
74
+ }[];
@@ -0,0 +1,41 @@
1
+ import { normalizeDeliveryTarget } from './delivery-target.js';
2
+ /** Reversible SQL text encoding, including endpoint ids containing NUL. */
3
+ export function encodeStorageIdentity(value) {
4
+ return `k1:${value.replaceAll('%', '%25').replaceAll('\0', '%00')}`;
5
+ }
6
+ export function decodeStorageIdentity(value) {
7
+ if (typeof value !== 'string' || !value.startsWith('k1:'))
8
+ throw new Error('Invalid stored identity');
9
+ return value.slice(3).replaceAll('%00', '\0').replaceAll('%25', '%');
10
+ }
11
+ export const TASK_TARGET_COLUMNS = {
12
+ endpoint_adapter: { type: 'text', nullable: false }, endpoint_id: { type: 'text', nullable: false },
13
+ conversation_kind: { type: 'text', nullable: false }, conversation_id: { type: 'text', nullable: false },
14
+ parent_kind: { type: 'text', nullable: true }, parent_id: { type: 'text', nullable: true },
15
+ thread_id: { type: 'text', nullable: true },
16
+ };
17
+ export function encodeTaskTarget(target) {
18
+ const value = normalizeDeliveryTarget(target);
19
+ return { endpoint_adapter: encodeStorageIdentity(value.endpoint.adapter), endpoint_id: encodeStorageIdentity(value.endpoint.id),
20
+ conversation_kind: value.kind, conversation_id: encodeStorageIdentity(value.id),
21
+ parent_kind: value.parent?.kind ?? null, parent_id: value.parent ? encodeStorageIdentity(value.parent.id) : null,
22
+ thread_id: value.threadId === undefined ? null : encodeStorageIdentity(value.threadId) };
23
+ }
24
+ export function decodeTaskTarget(row) {
25
+ return normalizeDeliveryTarget({ endpoint: { adapter: decodeStorageIdentity(row.endpoint_adapter), id: decodeStorageIdentity(row.endpoint_id) },
26
+ kind: row.conversation_kind, id: decodeStorageIdentity(row.conversation_id),
27
+ ...(row.parent_kind == null ? {} : { parent: { kind: row.parent_kind, id: decodeStorageIdentity(row.parent_id) } }),
28
+ ...(row.thread_id == null ? {} : { threadId: decodeStorageIdentity(row.thread_id) }) });
29
+ }
30
+ export const TASK_RECEIPT_COLUMNS = {
31
+ id: { type: 'text', length: 128, primary: true }, position: { type: 'integer', nullable: false },
32
+ fingerprint: { type: 'text', length: 64, nullable: false }, task_id: { type: 'text', length: 128, nullable: false },
33
+ operation: { type: 'text', length: 32, nullable: false },
34
+ };
35
+ export function encodeTaskReceipts(receipts) {
36
+ return receipts.map((receipt, position) => ({ id: receipt.id, fingerprint: receipt.fingerprint,
37
+ task_id: receipt.taskId, operation: receipt.operation, position }));
38
+ }
39
+ export function decodeTaskReceipts(rows) {
40
+ return rows.map(row => ({ id: String(row.id), fingerprint: String(row.fingerprint), taskId: String(row.task_id), operation: String(row.operation) }));
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-runtime",
3
- "version": "1.1.15",
3
+ "version": "1.1.16",
4
4
  "description": "Zero-dependency Plugin tree, Scope and atomic generation runtime for Zhin",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",