@zhin.js/plugin-runtime 1.1.15 → 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.
- package/lib/database-host.d.ts +16 -2
- package/lib/database-host.js +40 -2
- package/lib/i18n.d.ts +24 -0
- package/lib/i18n.js +38 -0
- package/lib/inbox.d.ts +22 -0
- package/lib/inbox.js +8 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/outbound-host.d.ts +9 -2
- package/lib/outbound-host.js +3 -1
- package/lib/plugin-console-rpc.d.ts +86 -0
- package/lib/plugin-console-rpc.js +69 -0
- package/lib/plugin-tasks.d.ts +11 -1
- package/lib/plugin-verification.d.ts +5 -0
- package/lib/plugin.d.ts +1 -1
- package/lib/relational-state.d.ts +26 -0
- package/lib/relational-state.js +130 -0
- package/lib/root-config-document.js +19 -3
- package/lib/task-storage.d.ts +74 -0
- package/lib/task-storage.js +41 -0
- package/package.json +1 -1
package/lib/database-host.d.ts
CHANGED
|
@@ -35,11 +35,20 @@ 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;
|
|
41
45
|
readonly columns?: Readonly<Record<string, unknown>>;
|
|
42
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
|
+
}
|
|
43
52
|
export interface DatabaseHostSelectResult {
|
|
44
53
|
readonly rows: readonly unknown[];
|
|
45
54
|
readonly total: number;
|
|
@@ -79,12 +88,14 @@ export interface DatabaseHost {
|
|
|
79
88
|
readonly dialect: string;
|
|
80
89
|
readonly started: boolean;
|
|
81
90
|
/** Register a table schema before `start` (column defs match `@zhin.js/database`). */
|
|
82
|
-
define(name: string, definition: Record<string, unknown
|
|
91
|
+
define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
|
|
83
92
|
/** Names of tables registered via `define` (Console `db:tables`). */
|
|
84
93
|
tables(): readonly string[];
|
|
85
94
|
models: {
|
|
86
95
|
get(name: string): DatabaseHostModel | undefined;
|
|
87
96
|
};
|
|
97
|
+
/** Atomic SQL operations. The callback must await all operations before returning. */
|
|
98
|
+
transaction?<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
|
|
88
99
|
/** Root-only management port; absent on lightweight test/memory hosts. */
|
|
89
100
|
readonly console?: DatabaseHostConsole;
|
|
90
101
|
/**
|
|
@@ -123,9 +134,12 @@ export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
123
134
|
get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
|
|
124
135
|
get dialect(): string;
|
|
125
136
|
get started(): boolean;
|
|
126
|
-
define(name: string, definition: Record<string, unknown
|
|
137
|
+
define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
|
|
127
138
|
tables(): readonly string[];
|
|
139
|
+
transaction<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
|
|
128
140
|
}
|
|
141
|
+
/** A saved query/model cannot escape the callback and reuse the transaction. */
|
|
142
|
+
export declare function guardTransactionModel(model: DatabaseHostModel, check: () => void): DatabaseHostModel;
|
|
129
143
|
/**
|
|
130
144
|
* Plugin-facing scoped persistence token. Runtime replaces this binding for
|
|
131
145
|
* every Plugin Scope; plugin code must never receive the process-wide host.
|
package/lib/database-host.js
CHANGED
|
@@ -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(() =>
|
|
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) => {
|
|
@@ -63,6 +65,27 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
63
65
|
return logical === undefined ? [] : [logical];
|
|
64
66
|
})));
|
|
65
67
|
}
|
|
68
|
+
async transaction(callback) {
|
|
69
|
+
if (!this.#host.transaction)
|
|
70
|
+
throw new Error('Database Host does not support transactions');
|
|
71
|
+
return this.#execute(() => this.#host.transaction(async (transaction) => {
|
|
72
|
+
let active = true;
|
|
73
|
+
const requireActive = () => { if (!active)
|
|
74
|
+
throw new Error('Database transaction is already closed'); };
|
|
75
|
+
try {
|
|
76
|
+
return await callback(Object.freeze({ model: (name) => {
|
|
77
|
+
requireActive();
|
|
78
|
+
// Reuse the ordinary facade so qualification and generation checks stay identical.
|
|
79
|
+
const model = transaction.model(this.qualify(name));
|
|
80
|
+
const guarded = guardTransactionModel(model, requireActive);
|
|
81
|
+
return this.#bindModel(guarded);
|
|
82
|
+
} }));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
active = false;
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
66
89
|
#bindSelection(selection) {
|
|
67
90
|
const modify = (operation) => this.#operation.setup(() => {
|
|
68
91
|
const next = operation();
|
|
@@ -94,6 +117,21 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
94
117
|
return this.#operation.run(operation);
|
|
95
118
|
}
|
|
96
119
|
}
|
|
120
|
+
/** A saved query/model cannot escape the callback and reuse the transaction. */
|
|
121
|
+
export function guardTransactionModel(model, check) {
|
|
122
|
+
const selection = (source) => ({
|
|
123
|
+
where(query) { check(); return selection(source.where(query)); },
|
|
124
|
+
orderBy(field, direction) { check(); return selection(source.orderBy(field, direction)); },
|
|
125
|
+
limit(count) { check(); return selection(source.limit(count)); },
|
|
126
|
+
then(resolve, reject) { return Promise.resolve().then(() => { check(); return source; }).then(resolve, reject); },
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
select: (...fields) => { check(); return selection(model.select(...fields)); },
|
|
130
|
+
insert: async (row) => { check(); return model.insert(row); },
|
|
131
|
+
update: patch => { check(); return { where: async (query) => { check(); return model.update(patch).where(query); } }; },
|
|
132
|
+
delete: () => { check(); return { where: async (query) => { check(); return model.delete().where(query); } }; },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
97
135
|
/**
|
|
98
136
|
* Plugin-facing scoped persistence token. Runtime replaces this binding for
|
|
99
137
|
* every Plugin Scope; plugin code must never receive the process-wide host.
|
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/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
|
@@ -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。 */
|
|
@@ -33,7 +34,10 @@ export * from './outbound-host.js';
|
|
|
33
34
|
export * from './outbound-sender.js';
|
|
34
35
|
export * from './delivery-target.js';
|
|
35
36
|
export * from './persistent-state.js';
|
|
37
|
+
export * from './relational-state.js';
|
|
36
38
|
export * from './inbox.js';
|
|
37
39
|
export * from './system-log.js';
|
|
38
40
|
export * from './plugin-verification.js';
|
|
39
41
|
export * from './plugin-tasks.js';
|
|
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。 */
|
|
@@ -33,7 +34,10 @@ export * from './outbound-host.js';
|
|
|
33
34
|
export * from './outbound-sender.js';
|
|
34
35
|
export * from './delivery-target.js';
|
|
35
36
|
export * from './persistent-state.js';
|
|
37
|
+
export * from './relational-state.js';
|
|
36
38
|
export * from './inbox.js';
|
|
37
39
|
export * from './system-log.js';
|
|
38
40
|
export * from './plugin-verification.js';
|
|
39
41
|
export * from './plugin-tasks.js';
|
|
42
|
+
export * from './task-storage.js';
|
|
43
|
+
export * from './plugin-console-rpc.js';
|
package/lib/outbound-host.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
package/lib/outbound-host.js
CHANGED
|
@@ -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(() =>
|
|
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
|
+
}
|
package/lib/plugin-tasks.d.ts
CHANGED
|
@@ -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(
|
|
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
|
@@ -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,26 @@
|
|
|
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
|
+
/** Explicit additions for an already persisted relational table. */
|
|
12
|
+
readonly addColumns?: Readonly<Record<string, readonly string[]>>;
|
|
13
|
+
encode(value: JsonValue): {
|
|
14
|
+
header: Record<string, unknown>;
|
|
15
|
+
rows: Record<string, readonly Record<string, unknown>[]>;
|
|
16
|
+
};
|
|
17
|
+
decode(header: Record<string, unknown>, rows: Record<string, readonly Record<string, unknown>[]>): JsonValue;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reuses PluginStateStore's CAS and admission contract with relational records.
|
|
21
|
+
* Only registered plugin keys are accepted; ordinary state uses database.state.
|
|
22
|
+
* Register in setup. Tables, revision and receipts commit or roll back together.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createRelationalStateStore(database: PluginDatabaseHost, codecs: readonly RelationalStateCodec[]): PluginStateStore;
|
|
25
|
+
/** Only for pure database mutations; never wrap external sends or arbitrary side effects. */
|
|
26
|
+
export declare function retryRelationalOperation<T>(operation: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,130 @@
|
|
|
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 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
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const migrated = new Set();
|
|
35
|
+
const writeRows = async (transaction, codec, rows) => {
|
|
36
|
+
for (const name of Object.keys(codec.tables)) {
|
|
37
|
+
const model = transaction.model(name);
|
|
38
|
+
await model.delete().where({});
|
|
39
|
+
for (const row of rows[name] ?? [])
|
|
40
|
+
await model.insert(row);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const migrate = async (codec) => {
|
|
44
|
+
if (migrated.has(codec.key))
|
|
45
|
+
return;
|
|
46
|
+
// Read outside the transaction: the old port may use another connection.
|
|
47
|
+
const old = await legacy.get(codec.key);
|
|
48
|
+
await retryRelationalOperation(() => database.transaction(async (transaction) => {
|
|
49
|
+
const model = transaction.model(codec.header.table);
|
|
50
|
+
if ((await model.select().where({ storage_id: 1 }))[0])
|
|
51
|
+
return;
|
|
52
|
+
const encoded = old === undefined ? { header: {}, rows: {} } : codec.encode(old);
|
|
53
|
+
await model.insert({ ...encoded.header, storage_id: 1, storage_revision: randomUUID(),
|
|
54
|
+
storage_present: old !== undefined, storage_expires_at: null });
|
|
55
|
+
await writeRows(transaction, codec, encoded.rows);
|
|
56
|
+
}));
|
|
57
|
+
// A durable header is the cutover marker, including an empty/deleted book.
|
|
58
|
+
// Cleanup failure is safe to retry; never reimport an old deleted task.
|
|
59
|
+
await retryRelationalOperation(() => legacy.delete(codec.key));
|
|
60
|
+
migrated.add(codec.key);
|
|
61
|
+
};
|
|
62
|
+
return new PluginStateStore(database.owner, {
|
|
63
|
+
async read(_owner, scope, key) {
|
|
64
|
+
const codec = byKey.get(key);
|
|
65
|
+
if (!codec || scope.kind !== 'plugin')
|
|
66
|
+
throw new Error(`Unregistered relational state key: ${key}`);
|
|
67
|
+
await migrate(codec);
|
|
68
|
+
return retryRelationalOperation(() => database.transaction(async (transaction) => {
|
|
69
|
+
const header = (await transaction.model(codec.header.table).select().where({ storage_id: 1 }))[0];
|
|
70
|
+
if (!header)
|
|
71
|
+
throw new Error('Relational state header is missing');
|
|
72
|
+
const rows = {};
|
|
73
|
+
for (const name of Object.keys(codec.tables))
|
|
74
|
+
rows[name] = await transaction.model(name).select().orderBy('position');
|
|
75
|
+
return { revision: String(header.storage_revision),
|
|
76
|
+
value: header.storage_present ? codec.decode(header, rows) : null,
|
|
77
|
+
// Tombstones must remain visible to CAS, but appear absent to the updater.
|
|
78
|
+
expiresAt: header.storage_present ? (header.storage_expires_at == null ? null : Number(header.storage_expires_at)) : 0 };
|
|
79
|
+
}));
|
|
80
|
+
},
|
|
81
|
+
async compareExchange(_owner, scope, key, revision, value, expiresAt) {
|
|
82
|
+
const codec = byKey.get(key);
|
|
83
|
+
if (!codec || scope.kind !== 'plugin')
|
|
84
|
+
throw new Error(`Unregistered relational state key: ${key}`);
|
|
85
|
+
if (revision === undefined)
|
|
86
|
+
throw new Error('Relational state must be read before CAS');
|
|
87
|
+
try {
|
|
88
|
+
return await database.transaction(async (transaction) => {
|
|
89
|
+
const encoded = value === undefined ? { header: {}, rows: {} } : codec.encode(value);
|
|
90
|
+
const changed = await transaction.model(codec.header.table).update({ ...encoded.header,
|
|
91
|
+
storage_revision: randomUUID(), storage_present: value !== undefined, storage_expires_at: expiresAt,
|
|
92
|
+
}).where({ storage_id: 1, storage_revision: revision });
|
|
93
|
+
if (changed !== 1)
|
|
94
|
+
return false;
|
|
95
|
+
await writeRows(transaction, codec, encoded.rows);
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!isRelationalContention(error))
|
|
101
|
+
throw error;
|
|
102
|
+
await setImmediate();
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Only for pure database mutations; never wrap external sends or arbitrary side effects. */
|
|
109
|
+
export async function retryRelationalOperation(operation) {
|
|
110
|
+
for (let attempt = 0;; attempt++) {
|
|
111
|
+
try {
|
|
112
|
+
return await operation();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (attempt >= 63 || !isRelationalContention(error))
|
|
116
|
+
throw error;
|
|
117
|
+
await setImmediate();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Constraint races and serialization conflicts may replay the pure updater. */
|
|
122
|
+
function isRelationalContention(error) {
|
|
123
|
+
if (!error || typeof error !== 'object')
|
|
124
|
+
return false;
|
|
125
|
+
const value = error;
|
|
126
|
+
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 ?? '')
|
|
127
|
+
|| [1062, 1213, 1205].includes(value.errno ?? 0)
|
|
128
|
+
|| [5, 517, 1555, 2067].includes(value.errcode ?? 0)
|
|
129
|
+
|| (value.cause !== undefined && isRelationalContention(value.cause));
|
|
130
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
@@ -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
|
+
}
|