@zhin.js/plugin-runtime 1.1.14 → 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.
- package/README.md +12 -0
- package/lib/database-host.d.ts +9 -0
- package/lib/database-host.js +36 -0
- package/lib/inbox.d.ts +22 -0
- package/lib/inbox.js +8 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/plugin-configuration.d.ts +2 -6
- package/lib/plugin-configuration.js +8 -6
- package/lib/plugin-tasks.d.ts +62 -0
- package/lib/plugin-tasks.js +11 -0
- package/lib/plugin.d.ts +1 -1
- package/lib/relational-state.d.ts +24 -0
- package/lib/relational-state.js +124 -0
- package/lib/root-config-document.d.ts +3 -0
- package/lib/snapshot.d.ts +5 -1
- package/lib/snapshot.js +10 -2
- package/lib/task-storage.d.ts +74 -0
- package/lib/task-storage.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,8 +27,20 @@ prepare shadow state -> validate -> handoff -> atomic commit
|
|
|
27
27
|
局部 HMR 只缩小 prepare/setup/dispose 范围;每次 commit 始终发布完整一致的 snapshot。
|
|
28
28
|
长期 socket、worker 或 admission loop 必须参加 generation handoff,不能成为模块级副作用。
|
|
29
29
|
|
|
30
|
+
`SnapshotReader.acquire()` 返回的 `SnapshotLease.retirementSignal` 在该运行代被新一代替换、
|
|
31
|
+
或 Root 关闭时同步触发,即使仍有 lease 持有旧资源。它供交互等待等需要随代结束的操作主动取消;
|
|
32
|
+
不会代替 `lease.release()`、强制中断普通旧操作或提前销毁资源。被拒绝的候选不会触发当前代的信号。
|
|
33
|
+
|
|
30
34
|
本包没有生产依赖。YAML、AJV、watcher、IM 与 AI 都属于上层 adapter 或 Runtime。
|
|
31
35
|
|
|
36
|
+
## 可选管理员任务端口
|
|
37
|
+
|
|
38
|
+
`PluginTaskProvider` 与 `pluginTaskProviderToken` 定义插件自己的管理员任务服务:插件通过 `provide()` 发布,Host 从当前运行代的所属 Scope 读取,不额外建立调度器。聊天发送者的个人任务不进入这个端口。
|
|
39
|
+
|
|
40
|
+
任务快照包含真实任务、`revision` 与有界持久 `receipts`。创建或修改提交客户端操作 UUID 和原 `expectedRevision`;同一操作必须按相同内容幂等核对,不同内容复用 ID 或过期版本均应拒绝。回执确认的是持久命令,不是外部消息投递。未拿到响应时先只读核对,不能自动重发。服务必须携带 `generationAdmissionSource` 并参与实例退役。
|
|
41
|
+
|
|
42
|
+
`PluginTaskError.message` 是可以向管理员展示的业务说明,不应含凭据或原始异常。Host 自己的输入、权限与运行代校验使用固定说明,普通异常不直接暴露。任务使用已有 `DeliveryTarget` 保留完整账号、会话、父容器与话题;`actions` 只列实际支持的操作,`unknown` 状态不能被包装成可安全重试。
|
|
43
|
+
|
|
32
44
|
验证:`pnpm --filter @zhin.js/plugin-runtime test && pnpm --filter @zhin.js/plugin-runtime build`。
|
|
33
45
|
|
|
34
46
|
详细契约见 [Kernel 与 Generation](../../../docs/architecture/target-implementation/kernel-and-generation.md)。
|
package/lib/database-host.d.ts
CHANGED
|
@@ -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.
|
package/lib/database-host.js
CHANGED
|
@@ -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,6 +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';
|
|
40
|
+
export * from './plugin-tasks.js';
|
|
41
|
+
export * from './task-storage.js';
|
package/lib/index.js
CHANGED
|
@@ -33,6 +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';
|
|
40
|
+
export * from './plugin-tasks.js';
|
|
41
|
+
export * from './task-storage.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Plugin business configuration keyed by the aliases in a v2 application declaration. */
|
|
2
2
|
export type PluginConfigurationMap = Readonly<Record<string, unknown>>;
|
|
3
3
|
/** Raised when a Root config still uses a non-canonical Plugin configuration shape. */
|
|
4
4
|
export declare class PluginConfigurationShapeError extends TypeError {
|
|
@@ -6,10 +6,6 @@ export declare class PluginConfigurationShapeError extends TypeError {
|
|
|
6
6
|
constructor(source?: string);
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
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.
|
|
9
|
+
* Projects top-level business blocks for the declared application instances.
|
|
14
10
|
*/
|
|
15
11
|
export declare function readPluginConfigurationMap(document: Readonly<Record<string, unknown>>, source?: string): PluginConfigurationMap;
|
|
@@ -9,18 +9,20 @@ export class PluginConfigurationShapeError extends TypeError {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
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.
|
|
12
|
+
* Projects top-level business blocks for the declared application instances.
|
|
17
13
|
*/
|
|
18
14
|
export function readPluginConfigurationMap(document, source = 'Root config') {
|
|
15
|
+
if (document.configVersion !== 2) {
|
|
16
|
+
throw new TypeError(`${source}.configVersion must be 2; see docs/migrations/plugin-instance-declarations.md`);
|
|
17
|
+
}
|
|
19
18
|
const value = document.plugins;
|
|
20
19
|
if (value === undefined)
|
|
21
20
|
return EMPTY_PLUGIN_CONFIGURATION;
|
|
22
21
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
23
22
|
throw new PluginConfigurationShapeError(source);
|
|
24
23
|
}
|
|
25
|
-
|
|
24
|
+
if (Object.values(value).some(reference => typeof reference !== 'string' || reference.length === 0)) {
|
|
25
|
+
throw new TypeError(`${source}.plugins must map instance aliases to package strings`);
|
|
26
|
+
}
|
|
27
|
+
return Object.freeze(Object.fromEntries(Object.keys(value).map(key => [key, document[key] === undefined ? {} : document[key]])));
|
|
26
28
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DeliveryTarget } from './delivery-target.js';
|
|
2
|
+
import type { JsonValue } from './persistent-state.js';
|
|
3
|
+
import type { GenerationAdmissionSource } from './admission.js';
|
|
4
|
+
export type PluginTaskState = 'scheduled' | 'paused' | 'running' | 'unknown' | 'succeeded' | 'failed' | 'cancelled';
|
|
5
|
+
export type PluginTaskAction = 'pause' | 'resume' | 'cancel' | 'update';
|
|
6
|
+
/** Business task facts. Chat-user tasks are deliberately outside this administrator port. */
|
|
7
|
+
export interface PluginTask {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly ownership: 'administrator';
|
|
10
|
+
readonly title: string;
|
|
11
|
+
readonly target: DeliveryTarget;
|
|
12
|
+
readonly state: PluginTaskState;
|
|
13
|
+
readonly createdAt: number;
|
|
14
|
+
readonly nextRunAt?: number;
|
|
15
|
+
readonly lastRunAt?: number;
|
|
16
|
+
readonly timezone?: string;
|
|
17
|
+
readonly detail?: string;
|
|
18
|
+
readonly parameters: Readonly<Record<string, JsonValue>>;
|
|
19
|
+
readonly actions: readonly PluginTaskAction[];
|
|
20
|
+
}
|
|
21
|
+
export interface PluginTaskSnapshot {
|
|
22
|
+
/** Changes whenever facts change. A stale mutation must fail instead of overwriting. */
|
|
23
|
+
readonly revision: string;
|
|
24
|
+
readonly tasks: readonly PluginTask[];
|
|
25
|
+
/** Bounded durable mutation acknowledgements, distinct from external delivery receipts. */
|
|
26
|
+
readonly receipts: readonly PluginTaskMutationReceipt[];
|
|
27
|
+
}
|
|
28
|
+
export interface PluginTaskMutationReceipt {
|
|
29
|
+
readonly requestId: string;
|
|
30
|
+
readonly taskId: string;
|
|
31
|
+
readonly operation: 'create' | PluginTaskAction;
|
|
32
|
+
}
|
|
33
|
+
export interface PluginTaskMutation {
|
|
34
|
+
/** Client-created operation UUID, retained for reconciliation after a lost response. */
|
|
35
|
+
readonly requestId: string;
|
|
36
|
+
readonly expectedRevision: string;
|
|
37
|
+
}
|
|
38
|
+
/** Optional plugin-owned task service, resolved from the current generation's owner scope. */
|
|
39
|
+
export interface PluginTaskProvider extends GenerationAdmissionSource {
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly description: string;
|
|
42
|
+
/** JSON Schema for this provider's creation/update parameters, never a Console-specific form. */
|
|
43
|
+
readonly schema: Readonly<Record<string, JsonValue>>;
|
|
44
|
+
/** Discloses future external effects before the operator creates a task. */
|
|
45
|
+
readonly effects: readonly string[];
|
|
46
|
+
read(): Promise<PluginTaskSnapshot>;
|
|
47
|
+
create(input: PluginTaskMutation & Readonly<{
|
|
48
|
+
target: DeliveryTarget;
|
|
49
|
+
parameters: Readonly<Record<string, JsonValue>>;
|
|
50
|
+
}>): Promise<PluginTaskSnapshot>;
|
|
51
|
+
change(input: PluginTaskMutation & Readonly<{
|
|
52
|
+
id: string;
|
|
53
|
+
action: PluginTaskAction;
|
|
54
|
+
parameters?: Readonly<Record<string, JsonValue>>;
|
|
55
|
+
}>): Promise<PluginTaskSnapshot>;
|
|
56
|
+
}
|
|
57
|
+
/** message is safe business guidance for administrators; never include secrets or raw exceptions. */
|
|
58
|
+
export declare class PluginTaskError extends Error {
|
|
59
|
+
readonly code: 'invalid_input' | 'conflict' | 'not_found' | 'unsupported' | 'unavailable';
|
|
60
|
+
constructor(code: PluginTaskError['code'], message: string);
|
|
61
|
+
}
|
|
62
|
+
export declare const pluginTaskProviderToken: import("./token.js").Token<PluginTaskProvider>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createToken } from './token.js';
|
|
2
|
+
/** message is safe business guidance for administrators; never include secrets or raw exceptions. */
|
|
3
|
+
export class PluginTaskError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PluginTaskError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const pluginTaskProviderToken = createToken('zhin.plugin-task-provider', 'Plugin-owned persistent administrator tasks, controlled only through authenticated Host operations');
|
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
|
+
}
|
|
@@ -10,10 +10,13 @@ export type ConfigPatch = {
|
|
|
10
10
|
export interface ConfigDocumentSnapshot {
|
|
11
11
|
readonly document: RuntimeConfigDocument;
|
|
12
12
|
readonly revision: string;
|
|
13
|
+
/** v2 declaration aliases in source order, including integer-like keys. */
|
|
14
|
+
readonly pluginOrder?: readonly string[];
|
|
13
15
|
}
|
|
14
16
|
/** A prepared write is inert until the generation handoff activates it. */
|
|
15
17
|
export interface PreparedConfigDocument {
|
|
16
18
|
readonly document: RuntimeConfigDocument;
|
|
19
|
+
readonly pluginOrder?: readonly string[];
|
|
17
20
|
commit(): Promise<ConfigDocumentSnapshot>;
|
|
18
21
|
rollback(): Promise<void>;
|
|
19
22
|
}
|
package/lib/snapshot.d.ts
CHANGED
|
@@ -50,7 +50,11 @@ export declare class SnapshotLease {
|
|
|
50
50
|
#private;
|
|
51
51
|
readonly value: RuntimeSnapshot;
|
|
52
52
|
private readonly releaseRecord;
|
|
53
|
-
|
|
53
|
+
/** Aborted on publication of a replacement or Root close, before lease disposal. */
|
|
54
|
+
readonly retirementSignal: AbortSignal;
|
|
55
|
+
constructor(value: RuntimeSnapshot, releaseRecord: () => void, authority: typeof snapshotLeaseAuthority,
|
|
56
|
+
/** Aborted on publication of a replacement or Root close, before lease disposal. */
|
|
57
|
+
retirementSignal: AbortSignal);
|
|
54
58
|
get active(): boolean;
|
|
55
59
|
release(): void;
|
|
56
60
|
}
|
package/lib/snapshot.js
CHANGED
|
@@ -13,10 +13,14 @@ const snapshotLeaseAuthority = Symbol('SnapshotLeaseAuthority');
|
|
|
13
13
|
export class SnapshotLease {
|
|
14
14
|
value;
|
|
15
15
|
releaseRecord;
|
|
16
|
+
retirementSignal;
|
|
16
17
|
#released = false;
|
|
17
|
-
constructor(value, releaseRecord, authority
|
|
18
|
+
constructor(value, releaseRecord, authority,
|
|
19
|
+
/** Aborted on publication of a replacement or Root close, before lease disposal. */
|
|
20
|
+
retirementSignal) {
|
|
18
21
|
this.value = value;
|
|
19
22
|
this.releaseRecord = releaseRecord;
|
|
23
|
+
this.retirementSignal = retirementSignal;
|
|
20
24
|
if (authority !== snapshotLeaseAuthority)
|
|
21
25
|
throw new TypeError('SnapshotLease is owned by SnapshotStore');
|
|
22
26
|
}
|
|
@@ -43,6 +47,7 @@ export class SnapshotStore {
|
|
|
43
47
|
const committed = Object.freeze({ snapshot, state: initialState });
|
|
44
48
|
const admissions = collectSnapshotAdmissions(snapshot);
|
|
45
49
|
const record = {
|
|
50
|
+
retirement: new AbortController(),
|
|
46
51
|
snapshot,
|
|
47
52
|
state: initialState,
|
|
48
53
|
committed,
|
|
@@ -65,7 +70,7 @@ export class SnapshotStore {
|
|
|
65
70
|
if (this.#closed)
|
|
66
71
|
throw new Error('SnapshotStore is closed');
|
|
67
72
|
const record = this.#current;
|
|
68
|
-
const lease = new SnapshotLease(record.snapshot, this.#retain(record), snapshotLeaseAuthority);
|
|
73
|
+
const lease = new SnapshotLease(record.snapshot, this.#retain(record), snapshotLeaseAuthority, record.retirement.signal);
|
|
69
74
|
this.#leases.add(lease);
|
|
70
75
|
return lease;
|
|
71
76
|
}
|
|
@@ -87,6 +92,7 @@ export class SnapshotStore {
|
|
|
87
92
|
const committed = Object.freeze({ snapshot, state });
|
|
88
93
|
const admissions = collectSnapshotAdmissions(snapshot);
|
|
89
94
|
const next = {
|
|
95
|
+
retirement: new AbortController(),
|
|
90
96
|
snapshot,
|
|
91
97
|
state,
|
|
92
98
|
committed,
|
|
@@ -99,6 +105,7 @@ export class SnapshotStore {
|
|
|
99
105
|
// Commit only swaps the active pointer. The previous record may still be
|
|
100
106
|
// serving in-flight work, so disposal is deferred to its final lease.
|
|
101
107
|
previous.retired = true;
|
|
108
|
+
previous.retirement.abort();
|
|
102
109
|
this.#retired.add(previous);
|
|
103
110
|
this.#disposeIfReady(previous);
|
|
104
111
|
return this.#current.committed;
|
|
@@ -109,6 +116,7 @@ export class SnapshotStore {
|
|
|
109
116
|
this.#closed = true;
|
|
110
117
|
replaceGenerationAdmissions(this.#current.admissions, new Set(), this, () => undefined);
|
|
111
118
|
this.#current.retired = true;
|
|
119
|
+
this.#current.retirement.abort();
|
|
112
120
|
this.#retired.add(this.#current);
|
|
113
121
|
this.#disposeIfReady(this.#current);
|
|
114
122
|
// A Root is not stopped while any historical generation still owns live
|
|
@@ -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
|
+
}
|