@mearl/client 2.20.6 → 2.21.0

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.
Files changed (105) hide show
  1. package/README.md +122 -2
  2. package/dist/cli.js +10 -5
  3. package/dist/commands.d.ts +14 -5
  4. package/dist/commands.js +21 -2
  5. package/dist/generated-browser-action-protocol.d.ts +11 -1
  6. package/dist/generated-browser-action-protocol.js +3 -0
  7. package/dist/generated-plugin-diagnostics.d.ts +110 -0
  8. package/dist/generated-plugin-diagnostics.js +1 -0
  9. package/dist/generated-plugin-execution.d.ts +27 -0
  10. package/dist/generated-plugin-execution.js +1 -0
  11. package/dist/generated-plugin-models.d.ts +64 -0
  12. package/dist/generated-plugin-models.js +1 -0
  13. package/dist/generated-plugin-pages.d.ts +76 -0
  14. package/dist/generated-plugin-pages.js +59 -0
  15. package/dist/generated-plugin-recovery.d.ts +32 -0
  16. package/dist/generated-plugin-recovery.js +1 -0
  17. package/dist/generated-plugin-state.d.ts +50 -0
  18. package/dist/generated-plugin-state.js +1 -0
  19. package/dist/generated-plugin-tasks.d.ts +74 -0
  20. package/dist/generated-plugin-tasks.js +7 -0
  21. package/dist/generated-toolbox-icon.d.ts +10 -0
  22. package/dist/generated-toolbox-icon.js +37 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.js +6 -0
  25. package/dist/plugin-task-host.d.ts +1 -0
  26. package/dist/plugin-task-host.js +14 -0
  27. package/dist/plugin-worker.d.ts +1 -0
  28. package/dist/plugin-worker.js +17 -0
  29. package/dist/plugin.d.ts +57 -4
  30. package/dist/plugin.js +296 -15
  31. package/dist/pluginCliOutput.d.ts +2 -0
  32. package/dist/pluginCliOutput.js +7 -0
  33. package/dist/pluginCommandWorker.d.ts +2 -0
  34. package/dist/pluginCommandWorker.js +174 -0
  35. package/dist/pluginDiagnostics.d.ts +26 -0
  36. package/dist/pluginDiagnostics.js +309 -0
  37. package/dist/pluginExecution.d.ts +21 -0
  38. package/dist/pluginExecution.js +43 -0
  39. package/dist/pluginExecutor.d.ts +29 -0
  40. package/dist/pluginExecutor.js +223 -0
  41. package/dist/pluginExecutorProtocol.d.ts +55 -0
  42. package/dist/pluginExecutorProtocol.js +47 -0
  43. package/dist/pluginExecutorSupervisor.d.ts +4 -0
  44. package/dist/pluginExecutorSupervisor.js +63 -0
  45. package/dist/pluginHost.d.ts +20 -0
  46. package/dist/pluginHost.js +53 -0
  47. package/dist/pluginHostConnection.d.ts +35 -0
  48. package/dist/pluginHostConnection.js +257 -0
  49. package/dist/pluginHostFiles.d.ts +1 -0
  50. package/dist/pluginHostFiles.js +21 -0
  51. package/dist/pluginHostPaths.d.ts +11 -0
  52. package/dist/pluginHostPaths.js +22 -0
  53. package/dist/pluginHostServer.d.ts +7 -0
  54. package/dist/pluginHostServer.js +252 -0
  55. package/dist/pluginModelCatalog.d.ts +14 -0
  56. package/dist/pluginModelCatalog.js +51 -0
  57. package/dist/pluginModelDefinition.d.ts +6 -0
  58. package/dist/pluginModelDefinition.js +75 -0
  59. package/dist/pluginModelSessionWorker.d.ts +23 -0
  60. package/dist/pluginModelSessionWorker.js +60 -0
  61. package/dist/pluginModelSessions.d.ts +16 -0
  62. package/dist/pluginModelSessions.js +234 -0
  63. package/dist/pluginModels.d.ts +17 -0
  64. package/dist/pluginModels.js +47 -0
  65. package/dist/pluginPageBundle.js +74 -17
  66. package/dist/pluginPageDefinition.d.ts +3 -0
  67. package/dist/pluginPageDefinition.js +62 -0
  68. package/dist/pluginPageManager.d.ts +57 -0
  69. package/dist/pluginPageManager.js +877 -0
  70. package/dist/pluginPages.d.ts +21 -0
  71. package/dist/pluginPages.js +46 -0
  72. package/dist/pluginProcessTree.d.ts +2 -0
  73. package/dist/pluginProcessTree.js +31 -0
  74. package/dist/pluginRecoveryDefinition.d.ts +3 -0
  75. package/dist/pluginRecoveryDefinition.js +56 -0
  76. package/dist/pluginRegistry.d.ts +2 -3
  77. package/dist/pluginRegistry.js +14 -13
  78. package/dist/pluginRevision.d.ts +14 -0
  79. package/dist/pluginRevision.js +227 -0
  80. package/dist/pluginRuntimeCleanup.d.ts +2 -0
  81. package/dist/pluginRuntimeCleanup.js +55 -0
  82. package/dist/pluginState.d.ts +26 -0
  83. package/dist/pluginState.js +28 -0
  84. package/dist/pluginStateDefinition.d.ts +4 -0
  85. package/dist/pluginStateDefinition.js +68 -0
  86. package/dist/pluginStateManager.d.ts +24 -0
  87. package/dist/pluginStateManager.js +132 -0
  88. package/dist/pluginStateStore.d.ts +57 -0
  89. package/dist/pluginStateStore.js +396 -0
  90. package/dist/pluginTaskManager.d.ts +92 -0
  91. package/dist/pluginTaskManager.js +1080 -0
  92. package/dist/pluginTaskProtocol.d.ts +47 -0
  93. package/dist/pluginTaskProtocol.js +1 -0
  94. package/dist/pluginTaskStore.d.ts +53 -0
  95. package/dist/pluginTaskStore.js +319 -0
  96. package/dist/pluginTaskWire.d.ts +4 -0
  97. package/dist/pluginTaskWire.js +42 -0
  98. package/dist/pluginTasks.d.ts +51 -0
  99. package/dist/pluginTasks.js +181 -0
  100. package/dist/pythonPlugin.d.ts +13 -2
  101. package/dist/pythonPlugin.js +235 -30
  102. package/dist/socket.js +5 -2
  103. package/dist/unified.d.ts +1 -0
  104. package/dist/unified.js +81 -0
  105. package/package.json +7 -3
@@ -0,0 +1,68 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Ajv } from 'ajv';
3
+ const validators = new WeakMap();
4
+ export function compilePluginStateSchema(schema) {
5
+ let validate = validators.get(schema);
6
+ if (!validate) {
7
+ validate = new Ajv({ strict: true }).compile(schema);
8
+ validators.set(schema, validate);
9
+ }
10
+ return validate;
11
+ }
12
+ export function normalizePluginState(value) {
13
+ if (value === undefined)
14
+ return;
15
+ const declaration = JSON.parse(JSON.stringify(value));
16
+ if (!declaration ||
17
+ declaration.version !== 1 ||
18
+ !declaration.collections ||
19
+ Array.isArray(declaration.collections) ||
20
+ Object.keys(declaration.collections).length > 32 ||
21
+ Buffer.byteLength(JSON.stringify(declaration)) > 128 * 1024)
22
+ throw new Error('Invalid stateStorage declaration');
23
+ for (const [name, collection] of Object.entries(declaration.collections)) {
24
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(name) ||
25
+ !collection ||
26
+ !['installation', 'host', 'browser-session', 'instance'].includes(collection.scope) ||
27
+ !['persistent', 'cache', 'memory'].includes(collection.storage) ||
28
+ !Number.isSafeInteger(collection.schemaVersion) ||
29
+ collection.schemaVersion < 1 ||
30
+ !collection.schema ||
31
+ typeof collection.schema !== 'object' ||
32
+ Array.isArray(collection.schema) ||
33
+ collection.schema.$async)
34
+ throw new Error(`Invalid state collection: ${name}`);
35
+ if (['browser-session', 'instance'].includes(collection.scope) &&
36
+ !['cache', 'memory'].includes(collection.storage))
37
+ throw new Error('Document state requires cache or memory storage');
38
+ if (collection.storage === 'memory' &&
39
+ !['browser-session', 'instance'].includes(collection.scope))
40
+ throw new Error('Memory state requires a browser session or instance scope');
41
+ if (collection.maxEntries !== undefined &&
42
+ (!Number.isSafeInteger(collection.maxEntries) ||
43
+ collection.maxEntries < 1 ||
44
+ collection.maxEntries > 4096))
45
+ throw new Error('State maxEntries must be between 1 and 4096');
46
+ if (collection.maxBytes !== undefined &&
47
+ (!Number.isSafeInteger(collection.maxBytes) ||
48
+ collection.maxBytes < 1 ||
49
+ collection.maxBytes > (collection.storage === 'memory' ? 128 : 16) * 1024 * 1024))
50
+ throw new Error('State maxBytes exceeds its storage limit');
51
+ if (collection.ttlMs !== undefined &&
52
+ (!Number.isSafeInteger(collection.ttlMs) ||
53
+ collection.ttlMs < 1 ||
54
+ collection.ttlMs > 7 * 86400000))
55
+ throw new Error('State TTL must be between 1 ms and 7 days');
56
+ if (collection.storage === 'persistent' && collection.ttlMs !== undefined)
57
+ throw new Error('Persistent state does not accept TTL');
58
+ compilePluginStateSchema(collection.schema);
59
+ }
60
+ return declaration;
61
+ }
62
+ export function pluginStateSignature(collection) {
63
+ return createHash('sha256')
64
+ .update(JSON.stringify(collection, (_key, value) => value && typeof value === 'object' && !Array.isArray(value)
65
+ ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
66
+ : value))
67
+ .digest('hex');
68
+ }
@@ -0,0 +1,24 @@
1
+ import type { PluginRevision } from './pluginRevision.js';
2
+ import { PluginStateStore } from './pluginStateStore.js';
3
+ import type { PluginTaskManager, PluginTaskBridge } from './pluginTaskManager.js';
4
+ import type { PluginOptions } from './plugin.js';
5
+ import type { PluginPageTarget } from './generated-plugin-pages.js';
6
+ export declare class PluginStateManager {
7
+ private readonly tasks;
8
+ readonly store: PluginStateStore;
9
+ private readonly descriptions;
10
+ private readonly pending;
11
+ private readonly controller;
12
+ resolveTarget?: (plugin: string, instanceId: unknown, bridge: PluginTaskBridge, options: PluginOptions) => Promise<{
13
+ target: PluginPageTarget;
14
+ assertActive(): void;
15
+ }>;
16
+ private readonly maintenance;
17
+ constructor(tasks: PluginTaskManager);
18
+ call(plugin: string, input: Record<string, unknown>, bridge: PluginTaskBridge, options?: PluginOptions, binding?: {
19
+ revision: PluginRevision;
20
+ signal?: AbortSignal;
21
+ expectedTarget?: PluginPageTarget;
22
+ }): Promise<unknown>;
23
+ close(): Promise<void>;
24
+ }
@@ -0,0 +1,132 @@
1
+ import { pluginStateSignature } from './pluginStateDefinition.js';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { findPluginRegistration } from './pluginRegistry.js';
5
+ import { pluginHostPaths } from './pluginHostPaths.js';
6
+ import { capturePluginRevision } from './pluginRevision.js';
7
+ import { PluginStateStore } from './pluginStateStore.js';
8
+ export class PluginStateManager {
9
+ tasks;
10
+ store = new PluginStateStore();
11
+ descriptions = new Map();
12
+ pending = new Set();
13
+ controller = new AbortController();
14
+ resolveTarget;
15
+ maintenance;
16
+ constructor(tasks) {
17
+ this.tasks = tasks;
18
+ this.store.prune();
19
+ this.maintenance = setInterval(() => {
20
+ try {
21
+ this.store.prune();
22
+ }
23
+ catch (error) {
24
+ console.error('Plugin cache maintenance failed:', error);
25
+ }
26
+ }, 60000);
27
+ this.maintenance.unref();
28
+ }
29
+ async call(plugin, input, bridge, options = {}, binding) {
30
+ this.controller.signal.throwIfAborted();
31
+ binding?.signal?.throwIfAborted();
32
+ if (!/^[a-z][a-z0-9-]*$/.test(plugin) ||
33
+ (bridge.scope.plugin && bridge.scope.plugin !== plugin) ||
34
+ !input ||
35
+ typeof input !== 'object' ||
36
+ Array.isArray(input))
37
+ throw new Error('Invalid plugin state scope');
38
+ const revision = binding?.revision ?? capturePluginRevision(plugin);
39
+ const id = `${revision.installationId}/${revision.revision}`;
40
+ let description = this.descriptions.get(id);
41
+ if (!description) {
42
+ if (this.descriptions.size >= 64)
43
+ this.descriptions.delete(this.descriptions.keys().next().value);
44
+ const release = this.tasks.pinRevision(revision);
45
+ description = this.tasks
46
+ .describeRevision(revision, this.controller.signal, { cache: true })
47
+ .finally(release);
48
+ this.descriptions.set(id, description);
49
+ this.pending.add(description);
50
+ void description.finally(() => this.pending.delete(description)).catch(() => { });
51
+ void description.catch(() => {
52
+ if (this.descriptions.get(id) === description)
53
+ this.descriptions.delete(id);
54
+ });
55
+ }
56
+ const declaration = (await description).stateStorage;
57
+ this.controller.signal.throwIfAborted();
58
+ binding?.signal?.throwIfAborted();
59
+ const name = String(input.collection ?? '');
60
+ const definition = declaration && Object.hasOwn(declaration.collections, name)
61
+ ? declaration.collections[name]
62
+ : undefined;
63
+ if (!definition || !/^[a-z][a-z0-9-]{0,63}$/.test(name))
64
+ throw Object.assign(new Error('Plugin state collection is not declared'), {
65
+ code: 'PLUGIN_STATE_NOT_DECLARED',
66
+ });
67
+ if (input.expectedDeclaration !== undefined &&
68
+ input.expectedDeclaration !== pluginStateSignature(definition))
69
+ throw Object.assign(new Error('Plugin state declaration changed during command execution'), {
70
+ code: 'PLUGIN_STATE_REVISION_CHANGED',
71
+ });
72
+ let scope = definition.scope;
73
+ let assertScope = () => { };
74
+ if (definition.scope === 'host')
75
+ scope += `:${this.tasks.host.hostId}`;
76
+ if (definition.scope === 'instance' || definition.scope === 'browser-session') {
77
+ if (!this.resolveTarget)
78
+ throw new Error('Page state scope is unavailable');
79
+ const resolved = await this.resolveTarget(plugin, input.instanceId, bridge, options);
80
+ const target = resolved.target;
81
+ const expected = binding?.expectedTarget;
82
+ if (expected &&
83
+ (target.browserSessionId !== expected.browserSessionId ||
84
+ target.tabId !== expected.tabId ||
85
+ target.frameId !== expected.frameId ||
86
+ target.documentId !== expected.documentId))
87
+ throw Object.assign(new Error('State access cannot change the inherited document'), {
88
+ code: 'PLUGIN_PAGE_TARGET_CHANGED',
89
+ });
90
+ assertScope = resolved.assertActive;
91
+ scope +=
92
+ definition.scope === 'instance'
93
+ ? `:${String(input.instanceId)}`
94
+ : `:${target.browserSessionId}`;
95
+ }
96
+ // No await occurs between the final target check and the atomic store operation.
97
+ this.controller.signal.throwIfAborted();
98
+ binding?.signal?.throwIfAborted();
99
+ let installation;
100
+ try {
101
+ installation = JSON.parse(readFileSync(join(pluginHostPaths().installations, `${plugin}.json`), 'utf8'));
102
+ }
103
+ catch {
104
+ /* Removed installations have no writable state scope. */
105
+ }
106
+ if (!findPluginRegistration(plugin) || installation?.installationId !== revision.installationId)
107
+ throw Object.assign(new Error('Plugin state installation was removed or replaced'), {
108
+ code: 'PLUGIN_STATE_SCOPE_ENDED',
109
+ });
110
+ assertScope();
111
+ const namespace = { installationId: revision.installationId, collection: name, scope };
112
+ switch (input.operation) {
113
+ case 'get':
114
+ return this.store.get(namespace, definition, input.key);
115
+ case 'get-many':
116
+ return this.store.getMany(namespace, definition, input.keys);
117
+ case 'list':
118
+ return this.store.list(namespace, definition, input);
119
+ case 'transact':
120
+ return this.store.transact(namespace, definition, input.transaction);
121
+ default:
122
+ throw new Error('Invalid state operation');
123
+ }
124
+ }
125
+ async close() {
126
+ clearInterval(this.maintenance);
127
+ this.controller.abort();
128
+ this.descriptions.clear();
129
+ this.store.clearMemory();
130
+ await Promise.allSettled([...this.pending]);
131
+ }
132
+ }
@@ -0,0 +1,57 @@
1
+ import type { PluginStateCollection, PluginStateEntry, PluginStateTransaction } from './generated-plugin-state.js';
2
+ export interface PluginStateNamespace {
3
+ installationId: string;
4
+ collection: string;
5
+ scope: string;
6
+ }
7
+ /** A collection namespace is one atomic record, including CAS and commit receipts. */
8
+ export declare class PluginStateStore {
9
+ readonly directory: string;
10
+ private readonly now;
11
+ private readonly memory;
12
+ private memoryBytes;
13
+ private memoryRevisionFloor;
14
+ clearMemory(): void;
15
+ private readonly valueBytes;
16
+ private memorySize;
17
+ private saveMemory;
18
+ constructor(directory?: string, now?: () => number);
19
+ private path;
20
+ private read;
21
+ private entry;
22
+ discardScope(installationId: string, scope: string): void;
23
+ prune(skipFile?: string): void;
24
+ get(namespace: PluginStateNamespace, definition: PluginStateCollection, name: string): {
25
+ revision: number;
26
+ schemaVersion: number;
27
+ key: string;
28
+ exists: boolean;
29
+ version: string;
30
+ value?: unknown;
31
+ expiresAt?: number;
32
+ };
33
+ getMany(namespace: PluginStateNamespace, definition: PluginStateCollection, names: unknown): {
34
+ entries: PluginStateEntry[];
35
+ revision: number;
36
+ schemaVersion: number;
37
+ };
38
+ list(namespace: PluginStateNamespace, definition: PluginStateCollection, input?: {
39
+ cursor?: string;
40
+ limit?: number;
41
+ expectedRevision?: number;
42
+ }): {
43
+ nextCursor?: string | undefined;
44
+ entries: PluginStateEntry[];
45
+ revision: number;
46
+ schemaVersion: number;
47
+ };
48
+ transact(namespace: PluginStateNamespace, definition: PluginStateCollection, input: PluginStateTransaction): {
49
+ revision: number;
50
+ schemaVersion: number;
51
+ reused: boolean;
52
+ } | {
53
+ revision: number;
54
+ schemaVersion: number;
55
+ reused?: undefined;
56
+ };
57
+ }
@@ -0,0 +1,396 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync } from 'node:fs';
3
+ import { join, sep } from 'node:path';
4
+ import { pluginHostPaths } from './pluginHostPaths.js';
5
+ import { writePluginHostFile } from './pluginHostFiles.js';
6
+ import { compilePluginStateSchema } from './pluginStateDefinition.js';
7
+ function fail(code, message) {
8
+ throw Object.assign(new Error(message), { code });
9
+ }
10
+ function schemaHash(definition) {
11
+ return createHash('sha256')
12
+ .update(JSON.stringify({ schema: definition.schema, storage: definition.storage }, (_key, value) => value && typeof value === 'object' && !Array.isArray(value)
13
+ ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
14
+ : value))
15
+ .digest('hex');
16
+ }
17
+ function key(value) {
18
+ if (typeof value !== 'string' ||
19
+ !value.length ||
20
+ Buffer.byteLength(value) > 512 ||
21
+ ['__proto__', 'constructor', 'prototype'].includes(value))
22
+ fail('PLUGIN_STATE_INVALID', 'Invalid state key');
23
+ }
24
+ /** A collection namespace is one atomic record, including CAS and commit receipts. */
25
+ export class PluginStateStore {
26
+ directory;
27
+ now;
28
+ memory = new Map();
29
+ memoryBytes = 0;
30
+ memoryRevisionFloor = 0;
31
+ clearMemory() {
32
+ this.memory.clear();
33
+ this.memoryBytes = 0;
34
+ this.memoryRevisionFloor = 0;
35
+ }
36
+ valueBytes = new WeakMap();
37
+ memorySize(document) {
38
+ let bytes = Buffer.byteLength(JSON.stringify({ ...document, entries: {} }));
39
+ let count = 0;
40
+ for (const [name, entry] of Object.entries(document.entries)) {
41
+ let valueBytes = typeof entry.value === 'object' && entry.value !== null
42
+ ? this.valueBytes.get(entry.value)
43
+ : undefined;
44
+ if (valueBytes === undefined) {
45
+ valueBytes = Buffer.byteLength(JSON.stringify(entry.value));
46
+ if (typeof entry.value === 'object' && entry.value !== null)
47
+ this.valueBytes.set(entry.value, valueBytes);
48
+ }
49
+ bytes +=
50
+ Buffer.byteLength(JSON.stringify(name)) +
51
+ 1 +
52
+ Buffer.byteLength(JSON.stringify({ ...entry, value: null })) -
53
+ 4 +
54
+ valueBytes +
55
+ (count++ ? 1 : 0);
56
+ }
57
+ return bytes;
58
+ }
59
+ saveMemory(path, document, bytes = this.memorySize(document)) {
60
+ const total = this.memoryBytes - (this.memory.get(path)?.bytes ?? 0) + bytes;
61
+ if (total > 128 * 1024 * 1024 || (!this.memory.has(path) && this.memory.size >= 512))
62
+ fail('PLUGIN_STATE_QUOTA', 'Memory state exceeds its host limits');
63
+ this.memory.set(path, { document, bytes });
64
+ this.memoryBytes = total;
65
+ }
66
+ constructor(directory = pluginHostPaths().state, now = Date.now) {
67
+ this.directory = directory;
68
+ this.now = now;
69
+ }
70
+ path(namespace) {
71
+ if (!/^[a-f0-9-]{36}$/i.test(namespace.installationId) ||
72
+ !/^[a-z][a-z0-9-]{0,63}$/.test(namespace.collection))
73
+ fail('PLUGIN_STATE_INVALID', 'Invalid state namespace');
74
+ const id = createHash('sha256').update(namespace.scope).digest('hex');
75
+ return join(this.directory, namespace.installationId, `${namespace.collection}-${id}.json`);
76
+ }
77
+ read(namespace, definition) {
78
+ try {
79
+ const file = this.path(namespace);
80
+ const saved = this.memory.get(file);
81
+ if ((definition.storage !== 'memory' && saved) ||
82
+ (definition.storage === 'memory' && !saved && existsSync(file)))
83
+ fail('PLUGIN_STATE_STORAGE_CHANGED', 'Switching between memory and disk state requires a new collection or namespace');
84
+ if (definition.storage === 'memory' && !saved) {
85
+ if (this.memory.size >= 512) {
86
+ // Retiring empty collections advances absence versions so stale CAS cannot be reused.
87
+ this.memoryRevisionFloor =
88
+ Math.max(this.memoryRevisionFloor, ...[...this.memory.values()].map(entry => entry.document.revision)) + 1;
89
+ for (const [path, entry] of this.memory)
90
+ if (!Object.keys(entry.document.entries).length) {
91
+ this.memory.delete(path);
92
+ this.memoryBytes -= entry.bytes;
93
+ }
94
+ }
95
+ throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
96
+ }
97
+ const document = saved
98
+ ? { ...saved.document, entries: { ...saved.document.entries } }
99
+ : JSON.parse(readFileSync(file, 'utf8'));
100
+ if (!Number.isSafeInteger(document.revision) ||
101
+ !Number.isSafeInteger(document.schemaVersion) ||
102
+ !document.entries ||
103
+ !Array.isArray(document.commits))
104
+ throw new Error('Invalid state document');
105
+ if (definition.storage !== 'persistent') {
106
+ const entries = Object.fromEntries(Object.entries(document.entries).filter(([, entry]) => entry.expiresAt === undefined || entry.expiresAt > this.now()));
107
+ if (Object.keys(entries).length !== Object.keys(document.entries).length) {
108
+ document.entries = entries;
109
+ document.revision++;
110
+ if (definition.storage === 'memory')
111
+ this.saveMemory(file, document);
112
+ else
113
+ writePluginHostFile(file, JSON.stringify(document));
114
+ }
115
+ }
116
+ return document;
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'ENOENT')
120
+ throw error;
121
+ return {
122
+ revision: definition.storage === 'memory' ? this.memoryRevisionFloor : 0,
123
+ schemaVersion: definition.schemaVersion,
124
+ schemaHash: schemaHash(definition),
125
+ storage: definition.storage,
126
+ scope: namespace.scope,
127
+ entries: {},
128
+ commits: [],
129
+ };
130
+ }
131
+ }
132
+ entry(document, name, now) {
133
+ const entry = Object.hasOwn(document.entries, name) ? document.entries[name] : undefined;
134
+ return entry && (entry.expiresAt === undefined || entry.expiresAt > now)
135
+ ? {
136
+ key: name,
137
+ exists: true,
138
+ version: `v:${entry.revision}`,
139
+ value: document.storage === 'memory' ? structuredClone(entry.value) : entry.value,
140
+ ...(entry.expiresAt === undefined ? {} : { expiresAt: entry.expiresAt }),
141
+ }
142
+ : { key: name, exists: false, version: `a:${document.revision}` };
143
+ }
144
+ discardScope(installationId, scope) {
145
+ if (!/^[a-f0-9-]{36}$/i.test(installationId) || !scope.startsWith('instance:pi_'))
146
+ throw new Error('Only ended instance scopes can be discarded');
147
+ const directory = join(this.directory, installationId);
148
+ const memorySuffix = `-${createHash('sha256').update(scope).digest('hex')}.json`;
149
+ for (const [path, entry] of this.memory)
150
+ if (path.startsWith(directory + sep) && path.endsWith(memorySuffix)) {
151
+ this.memory.delete(path);
152
+ this.memoryBytes -= entry.bytes;
153
+ }
154
+ const suffix = `-${createHash('sha256').update(scope).digest('hex')}.json`;
155
+ let names;
156
+ try {
157
+ names = readdirSync(directory);
158
+ }
159
+ catch (error) {
160
+ if (error.code === 'ENOENT')
161
+ return;
162
+ throw error;
163
+ }
164
+ for (const name of names)
165
+ if (name.endsWith(suffix))
166
+ rmSync(join(directory, name), { force: true });
167
+ }
168
+ prune(skipFile) {
169
+ for (const [path, saved] of this.memory) {
170
+ const document = { ...saved.document, entries: { ...saved.document.entries } };
171
+ for (const [key, entry] of Object.entries(document.entries))
172
+ if (entry.expiresAt !== undefined && entry.expiresAt <= this.now())
173
+ delete document.entries[key];
174
+ if (Object.keys(document.entries).length === Object.keys(saved.document.entries).length)
175
+ continue;
176
+ document.revision++;
177
+ this.saveMemory(path, document);
178
+ }
179
+ let installations;
180
+ try {
181
+ installations = readdirSync(this.directory);
182
+ }
183
+ catch (error) {
184
+ if (error.code === 'ENOENT')
185
+ return;
186
+ throw error;
187
+ }
188
+ for (const installation of installations) {
189
+ if (!/^[a-f0-9-]{36}$/i.test(installation))
190
+ continue;
191
+ for (const name of readdirSync(join(this.directory, installation))) {
192
+ if (!name.endsWith('.json'))
193
+ continue;
194
+ const file = join(this.directory, installation, name);
195
+ if (file === skipFile)
196
+ continue;
197
+ try {
198
+ const document = JSON.parse(readFileSync(file, 'utf8'));
199
+ if (document.storage !== 'cache')
200
+ continue;
201
+ const entries = Object.fromEntries(Object.entries(document.entries).filter(([, value]) => value.expiresAt === undefined || value.expiresAt > this.now()));
202
+ if (Object.keys(entries).length === Object.keys(document.entries).length)
203
+ continue;
204
+ writePluginHostFile(file, JSON.stringify({ ...document, revision: document.revision + 1, entries }));
205
+ }
206
+ catch (error) {
207
+ console.error(`Cannot maintain plugin cache ${file}:`, error);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ get(namespace, definition, name) {
213
+ key(name);
214
+ const document = this.read(namespace, definition);
215
+ return {
216
+ ...this.entry(document, name, this.now()),
217
+ revision: document.revision,
218
+ schemaVersion: document.schemaVersion,
219
+ };
220
+ }
221
+ getMany(namespace, definition, names) {
222
+ if (!Array.isArray(names) ||
223
+ names.length < 1 ||
224
+ names.length > 128 ||
225
+ new Set(names).size !== names.length)
226
+ fail('PLUGIN_STATE_INVALID', 'Expected 1 to 128 unique state keys');
227
+ names.forEach(name => key(name));
228
+ const document = this.read(namespace, definition), now = this.now();
229
+ const entries = names.map(name => this.entry(document, name, now));
230
+ if (Buffer.byteLength(JSON.stringify(entries)) > 1024 * 1024)
231
+ fail('PLUGIN_STATE_RESPONSE_TOO_LARGE', 'State batch exceeds 1 MiB; read fewer keys');
232
+ return { entries, revision: document.revision, schemaVersion: document.schemaVersion };
233
+ }
234
+ list(namespace, definition, input = {}) {
235
+ const document = this.read(namespace, definition);
236
+ const limit = input.limit ?? 100;
237
+ if (!Number.isSafeInteger(limit) ||
238
+ limit < 1 ||
239
+ limit > 200 ||
240
+ (input.cursor !== undefined && typeof input.cursor !== 'string'))
241
+ fail('PLUGIN_STATE_INVALID', 'Invalid state list cursor or limit');
242
+ if (input.expectedRevision !== undefined && input.expectedRevision !== document.revision)
243
+ fail('PLUGIN_STATE_CONFLICT', 'State list changed; restart pagination');
244
+ const now = this.now();
245
+ const candidates = Object.keys(document.entries)
246
+ .sort()
247
+ .filter(name => (input.cursor === undefined || name > input.cursor) &&
248
+ this.entry(document, name, now).exists);
249
+ const entries = [];
250
+ let bytes = 0;
251
+ for (const name of candidates) {
252
+ const entry = this.entry(document, name, now), size = Buffer.byteLength(JSON.stringify(entry));
253
+ if (entries.length && (entries.length >= limit || bytes + size > 256 * 1024))
254
+ break;
255
+ entries.push(entry);
256
+ bytes += size;
257
+ }
258
+ return {
259
+ entries,
260
+ revision: document.revision,
261
+ schemaVersion: document.schemaVersion,
262
+ ...(entries.length < candidates.length ? { nextCursor: entries.at(-1).key } : {}),
263
+ };
264
+ }
265
+ transact(namespace, definition, input) {
266
+ if (!input ||
267
+ !Array.isArray(input.operations) ||
268
+ input.operations.length > 128 ||
269
+ Buffer.byteLength(JSON.stringify(input)) > 512 * 1024)
270
+ fail('PLUGIN_STATE_INVALID', 'State transaction exceeds its limits');
271
+ const document = this.read(namespace, definition);
272
+ const hash = createHash('sha256')
273
+ .update(JSON.stringify({
274
+ input,
275
+ schemaVersion: definition.schemaVersion,
276
+ schema: definition.schema,
277
+ }))
278
+ .digest('hex');
279
+ if (input.idempotencyKey !== undefined) {
280
+ if (typeof input.idempotencyKey !== 'string' ||
281
+ !input.idempotencyKey.length ||
282
+ input.idempotencyKey.length > 128)
283
+ fail('PLUGIN_STATE_INVALID', 'Invalid transaction idempotency key');
284
+ const existing = document.commits.find(item => item.id === input.idempotencyKey);
285
+ if (existing) {
286
+ if (existing.hash !== hash)
287
+ fail('PLUGIN_STATE_IDEMPOTENCY_CONFLICT', 'Transaction key refers to different data or schema');
288
+ return { revision: existing.revision, schemaVersion: document.schemaVersion, reused: true };
289
+ }
290
+ }
291
+ if (input.expectedRevision !== undefined && input.expectedRevision !== document.revision)
292
+ fail('PLUGIN_STATE_CONFLICT', 'Collection revision changed');
293
+ if (document.schemaVersion === definition.schemaVersion &&
294
+ document.schemaHash !== schemaHash(definition))
295
+ fail('PLUGIN_STATE_SCHEMA_VERSION_REQUIRED', 'Changing the state schema or storage policy requires a new schemaVersion');
296
+ if (document.schemaVersion > definition.schemaVersion)
297
+ fail('PLUGIN_STATE_SCHEMA_DOWNGRADE', 'An older plugin revision cannot write a newer state schema');
298
+ if (document.schemaVersion !== definition.schemaVersion &&
299
+ (input.migrateFromVersion !== document.schemaVersion || input.expectedRevision === undefined))
300
+ fail('PLUGIN_STATE_MIGRATION_REQUIRED', 'Schema migration requires the previous schema version and collection revision');
301
+ if (input.migrateFromVersion !== undefined &&
302
+ input.migrateFromVersion !== document.schemaVersion)
303
+ fail('PLUGIN_STATE_CONFLICT', 'State schema changed');
304
+ const now = this.now(), names = new Set();
305
+ const entries = Object.fromEntries(Object.entries(document.entries)
306
+ .filter(([, value]) => value.expiresAt === undefined || value.expiresAt > now)
307
+ .map(([name, value]) => [name, { ...value }]));
308
+ const revision = document.revision + 1;
309
+ if (!Number.isSafeInteger(revision))
310
+ fail('PLUGIN_STATE_QUOTA', 'State revision limit reached');
311
+ if (document.storage !== definition.storage)
312
+ for (const entry of Object.values(entries)) {
313
+ entry.revision = revision;
314
+ if (definition.storage !== 'persistent')
315
+ entry.expiresAt = now + (definition.ttlMs ?? 86400000);
316
+ else
317
+ delete entry.expiresAt;
318
+ }
319
+ const validate = compilePluginStateSchema(definition.schema);
320
+ for (const operation of input.operations) {
321
+ key(operation.key);
322
+ if (names.has(operation.key) ||
323
+ !['put', 'delete'].includes(operation.type) ||
324
+ operation.expectedVersion !== this.entry(document, operation.key, now).version)
325
+ fail('PLUGIN_STATE_CONFLICT', 'State entry version changed or key repeated');
326
+ names.add(operation.key);
327
+ if (operation.type === 'delete') {
328
+ delete entries[operation.key];
329
+ continue;
330
+ }
331
+ if (!validate(operation.value) ||
332
+ Buffer.byteLength(JSON.stringify(operation.value)) > 256 * 1024)
333
+ fail('PLUGIN_STATE_SCHEMA_INVALID', 'State value does not match the declared schema or exceeds 256 KiB');
334
+ const ttl = operation.ttlMs ?? definition.ttlMs ?? 86400000;
335
+ if (definition.storage === 'persistent' && operation.ttlMs !== undefined)
336
+ fail('PLUGIN_STATE_INVALID', 'Persistent state does not accept TTL');
337
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > 7 * 86400000)
338
+ fail('PLUGIN_STATE_INVALID', 'Invalid cache TTL');
339
+ entries[operation.key] = {
340
+ revision,
341
+ value: definition.storage === 'memory' ? structuredClone(operation.value) : operation.value,
342
+ ...(definition.storage !== 'persistent' ? { expiresAt: now + ttl } : {}),
343
+ };
344
+ }
345
+ for (const entry of Object.values(entries))
346
+ if (!validate(entry.value))
347
+ fail('PLUGIN_STATE_SCHEMA_INVALID', 'Migration leaves entries incompatible with the target schema');
348
+ const commit = {
349
+ revision,
350
+ schemaVersion: definition.schemaVersion,
351
+ schemaHash: schemaHash(definition),
352
+ storage: definition.storage,
353
+ scope: namespace.scope,
354
+ entries,
355
+ commits: [
356
+ ...document.commits,
357
+ ...(input.idempotencyKey ? [{ id: input.idempotencyKey, hash, revision }] : []),
358
+ ].slice(-64),
359
+ };
360
+ const content = definition.storage === 'memory' ? '' : JSON.stringify(commit);
361
+ const bytes = definition.storage === 'memory' ? this.memorySize(commit) : Buffer.byteLength(content);
362
+ if (Object.keys(entries).length > (definition.maxEntries ?? 1024) ||
363
+ bytes > (definition.maxBytes ?? 8 * 1024 * 1024))
364
+ fail('PLUGIN_STATE_QUOTA', 'State collection quota exceeded');
365
+ const file = this.path(namespace);
366
+ if (definition.storage === 'memory') {
367
+ this.saveMemory(file, commit, bytes);
368
+ return { revision, schemaVersion: definition.schemaVersion };
369
+ }
370
+ let total = Buffer.byteLength(content);
371
+ try {
372
+ for (const name of readdirSync(join(this.directory, namespace.installationId))) {
373
+ const path = join(this.directory, namespace.installationId, name);
374
+ if (path !== file && name.endsWith('.json'))
375
+ total += statSync(path).size;
376
+ }
377
+ }
378
+ catch (error) {
379
+ if (error.code !== 'ENOENT')
380
+ throw error;
381
+ }
382
+ if (total > 64 * 1024 * 1024) {
383
+ this.prune(file);
384
+ total = Buffer.byteLength(content);
385
+ for (const name of readdirSync(join(this.directory, namespace.installationId))) {
386
+ const path = join(this.directory, namespace.installationId, name);
387
+ if (path !== file && name.endsWith('.json'))
388
+ total += statSync(path).size;
389
+ }
390
+ }
391
+ if (total > 64 * 1024 * 1024)
392
+ fail('PLUGIN_STATE_QUOTA', 'Plugin state exceeds 64 MiB');
393
+ writePluginHostFile(file, content);
394
+ return { revision, schemaVersion: definition.schemaVersion };
395
+ }
396
+ }