spexcode 0.6.6 → 0.6.8
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 -7
- package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
- package/node_modules/@spexcode/session-application/dist/index.js +35 -0
- package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
- package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
- package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
- package/node_modules/@spexcode/session-application/dist/production.js +438 -0
- package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
- package/node_modules/@spexcode/session-application/package.json +29 -0
- package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
- package/node_modules/@spexcode/session-events/dist/index.js +245 -0
- package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
- package/node_modules/@spexcode/session-events/package.json +24 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
- package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
- package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
- package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
- package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
- package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
- package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
- package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
- package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
- package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
- package/node_modules/@spexcode/session-runtime/package.json +24 -0
- package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
- package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +20 -0
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +83 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
- package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
- package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
- package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
- package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
- package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
- package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
- package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
- package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
- package/node_modules/@spexcode/session-topology/package.json +24 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +56 -19
- package/node_modules/@spexcode/spec-cli/dist/cli.js +102 -59
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
- package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +2 -2
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
- package/node_modules/@spexcode/spec-cli/dist/guide.js +20 -7
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -3
- package/node_modules/@spexcode/spec-cli/dist/harness.js +331 -50
- package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +93 -0
- package/node_modules/@spexcode/spec-cli/dist/index.js +324 -22
- package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
- package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
- package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
- package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
- package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
- package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
- package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +108 -15
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +1465 -744
- package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
- package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.d.ts +36 -0
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +251 -0
- package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
- package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
- package/node_modules/@spexcode/spec-cli/package.json +6 -6
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
- package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
- package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
- package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
- package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -0
- package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
- package/node_modules/@spexcode/spec-core/package.json +4 -1
- package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +9 -3
- package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -23
- package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -195
- package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
- package/node_modules/@spexcode/session-core/dist/index.js +0 -5
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
- package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
- package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
- package/node_modules/@spexcode/session-core/dist/message.js +0 -52
- package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -50
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -286
- package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
- package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -46
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { LocalityError, requireLocalDatabasePath } from './locality.js';
|
|
2
|
+
import { DatabasePathError, resolveDatabasePath } from './path.js';
|
|
3
|
+
class UsageError extends Error {
|
|
4
|
+
code = 'USAGE';
|
|
5
|
+
}
|
|
6
|
+
const VALUE_FLAGS = {
|
|
7
|
+
initialize: new Set(['session-id', 'database-path']),
|
|
8
|
+
enqueue: new Set(['session-id', 'kind', 'body', 'sender-session-id', 'idempotency-key', 'database-path']),
|
|
9
|
+
dequeue: new Set(['session-id', 'database-path']),
|
|
10
|
+
pending: new Set(['session-id', 'database-path']),
|
|
11
|
+
};
|
|
12
|
+
const usage = () => {
|
|
13
|
+
throw new UsageError('usage: spex-session initialize|enqueue|dequeue|pending --session-id ID [command options]');
|
|
14
|
+
};
|
|
15
|
+
const isCommandName = (value) => (value === 'initialize' || value === 'enqueue' || value === 'dequeue' || value === 'pending');
|
|
16
|
+
export function parseCommand(argv) {
|
|
17
|
+
const command = argv[0];
|
|
18
|
+
if (!isCommandName(command))
|
|
19
|
+
return usage();
|
|
20
|
+
const commandName = command;
|
|
21
|
+
const values = new Map();
|
|
22
|
+
const headers = [];
|
|
23
|
+
let assumeLocal = false;
|
|
24
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
25
|
+
const token = argv[index];
|
|
26
|
+
if (token === '--assume-local-storage') {
|
|
27
|
+
if (assumeLocal)
|
|
28
|
+
throw new UsageError('duplicate --assume-local-storage');
|
|
29
|
+
assumeLocal = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!token.startsWith('--'))
|
|
33
|
+
throw new UsageError(`unexpected argument ${token}`);
|
|
34
|
+
const name = token.slice(2);
|
|
35
|
+
if (name === 'header' && commandName === 'enqueue') {
|
|
36
|
+
const raw = argv[++index];
|
|
37
|
+
if (raw === undefined)
|
|
38
|
+
throw new UsageError('missing value for --header');
|
|
39
|
+
const separator = raw.indexOf('=');
|
|
40
|
+
if (separator <= 0)
|
|
41
|
+
throw new UsageError('--header must be K=V');
|
|
42
|
+
headers.push([raw.slice(0, separator), raw.slice(separator + 1)]);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!VALUE_FLAGS[commandName].has(name))
|
|
46
|
+
throw new UsageError(`unknown option --${name}`);
|
|
47
|
+
if (values.has(name))
|
|
48
|
+
throw new UsageError(`duplicate --${name}`);
|
|
49
|
+
const value = argv[++index];
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
throw new UsageError(`missing value for --${name}`);
|
|
52
|
+
values.set(name, value);
|
|
53
|
+
}
|
|
54
|
+
if (!values.has('session-id'))
|
|
55
|
+
throw new UsageError('missing --session-id');
|
|
56
|
+
if (commandName === 'enqueue' && !values.has('kind'))
|
|
57
|
+
throw new UsageError('missing --kind');
|
|
58
|
+
if (commandName === 'enqueue' && !values.has('body'))
|
|
59
|
+
throw new UsageError('missing --body');
|
|
60
|
+
return { command: commandName, values, headers, assumeLocal };
|
|
61
|
+
}
|
|
62
|
+
const renderMessage = (message) => {
|
|
63
|
+
const { body, ...fields } = message;
|
|
64
|
+
return { ...fields, bodyBase64: Buffer.from(body).toString('base64') };
|
|
65
|
+
};
|
|
66
|
+
const errorCode = (error) => {
|
|
67
|
+
if (error instanceof UsageError)
|
|
68
|
+
return error.code;
|
|
69
|
+
if (error instanceof DatabasePathError)
|
|
70
|
+
return error.code;
|
|
71
|
+
if (error instanceof LocalityError)
|
|
72
|
+
return error.code;
|
|
73
|
+
if (typeof error === 'object'
|
|
74
|
+
&& error !== null
|
|
75
|
+
&& 'code' in error
|
|
76
|
+
&& typeof error.code === 'string'
|
|
77
|
+
&& error.code.startsWith('PROTOCOL_'))
|
|
78
|
+
return error.code;
|
|
79
|
+
return 'INTERNAL';
|
|
80
|
+
};
|
|
81
|
+
const SQLITE_EXPERIMENTAL_WARNING = 'SQLite is an experimental feature and might change at any time';
|
|
82
|
+
// @@@node-sqlite-warning - Node 22 emits this during import; suppress only that exact warning so the
|
|
83
|
+
// CLI's stderr remains its frozen single line while every unrelated warning keeps its normal path.
|
|
84
|
+
const loadOpenProtocol = async () => {
|
|
85
|
+
const originalEmitWarning = process.emitWarning;
|
|
86
|
+
process.emitWarning = ((warning, ...args) => {
|
|
87
|
+
const message = warning instanceof Error ? warning.message : warning;
|
|
88
|
+
if (message === SQLITE_EXPERIMENTAL_WARNING)
|
|
89
|
+
return;
|
|
90
|
+
Reflect.apply(originalEmitWarning, process, [warning, ...args]);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return (await import('@spexcode/session-protocol')).openProtocol;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
process.emitWarning = originalEmitWarning;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
export async function runCli(options = {}) {
|
|
100
|
+
const stdout = options.stdout ?? (text => process.stdout.write(text));
|
|
101
|
+
const stderr = options.stderr ?? (text => process.stderr.write(text));
|
|
102
|
+
let protocol;
|
|
103
|
+
try {
|
|
104
|
+
const parsed = parseCommand(options.argv ?? process.argv.slice(2));
|
|
105
|
+
const databasePath = resolveDatabasePath({
|
|
106
|
+
databasePath: parsed.values.get('database-path'),
|
|
107
|
+
env: options.env,
|
|
108
|
+
});
|
|
109
|
+
const dependencies = options.dependencies ?? {
|
|
110
|
+
open: await loadOpenProtocol(),
|
|
111
|
+
requireLocal: requireLocalDatabasePath,
|
|
112
|
+
};
|
|
113
|
+
dependencies.requireLocal(databasePath, { assumeLocal: parsed.assumeLocal });
|
|
114
|
+
protocol = dependencies.open(databasePath);
|
|
115
|
+
const sessionId = parsed.values.get('session-id');
|
|
116
|
+
let result;
|
|
117
|
+
if (parsed.command === 'initialize') {
|
|
118
|
+
result = protocol.initialize(sessionId);
|
|
119
|
+
}
|
|
120
|
+
else if (parsed.command === 'enqueue') {
|
|
121
|
+
const headers = Object.fromEntries(parsed.headers);
|
|
122
|
+
result = renderMessage(protocol.enqueue(sessionId, {
|
|
123
|
+
kind: parsed.values.get('kind'),
|
|
124
|
+
body: Buffer.from(parsed.values.get('body'), 'utf8'),
|
|
125
|
+
...(parsed.values.has('sender-session-id')
|
|
126
|
+
? { senderSessionId: parsed.values.get('sender-session-id') }
|
|
127
|
+
: {}),
|
|
128
|
+
...(parsed.values.has('idempotency-key')
|
|
129
|
+
? { idempotencyKey: parsed.values.get('idempotency-key') }
|
|
130
|
+
: {}),
|
|
131
|
+
...(parsed.headers.length > 0 ? { headers } : {}),
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
else if (parsed.command === 'dequeue') {
|
|
135
|
+
const message = protocol.dequeue(sessionId);
|
|
136
|
+
result = message === null ? null : renderMessage(message);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
result = protocol.listPending(sessionId).map(renderMessage);
|
|
140
|
+
}
|
|
141
|
+
stdout(`${JSON.stringify(result)}\n`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const code = errorCode(error);
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
const repair = code === 'PROTOCOL_PATH_PARENT_MISSING'
|
|
148
|
+
? '; create the parent directory or choose --database-path with an existing parent'
|
|
149
|
+
: '';
|
|
150
|
+
stderr(`spex-session: ${code}: ${message}${repair}\n`);
|
|
151
|
+
return code === 'USAGE' ? 2 : 1;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
protocol?.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { LocalityError, requireLocalDatabasePath } from './locality.js';
|
|
2
|
+
export type { LocalityRefusalCode } from './locality.js';
|
|
3
|
+
export { resolveDatabasePath } from './path.js';
|
|
4
|
+
export type { ResolveDatabasePathOptions, SelfLaunchEnvironment } from './path.js';
|
|
5
|
+
import type { ProtocolTransaction } from '@spexcode/session-protocol';
|
|
6
|
+
import { type BindingOptions, type RuntimeBinding } from '@spexcode/session-runtime';
|
|
7
|
+
/**
|
|
8
|
+
* Native identity supplied by the harness adapter that owns this self-launch.
|
|
9
|
+
* The CLI session id is deliberately not accepted as a native identity.
|
|
10
|
+
*/
|
|
11
|
+
export interface SelfLaunchRuntimeIdentity {
|
|
12
|
+
nativeSessionId: string;
|
|
13
|
+
nativeStartToken: string;
|
|
14
|
+
metadata?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface SelfLaunchProtocol {
|
|
17
|
+
withTransaction<T>(body: (tx: ProtocolTransaction) => T): T;
|
|
18
|
+
}
|
|
19
|
+
export interface SelfLaunchBindingOptions extends BindingOptions {
|
|
20
|
+
runtimeKind?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Attach a caller-owned native harness identity to an existing protocol address.
|
|
24
|
+
* This is the only self-launch/runtime seam; it does not launch, probe, or stop a harness.
|
|
25
|
+
*/
|
|
26
|
+
export declare function bindSelfLaunchRuntime(protocol: SelfLaunchProtocol, protocolSessionId: string, identity: SelfLaunchRuntimeIdentity, options?: SelfLaunchBindingOptions): RuntimeBinding;
|
|
27
|
+
export declare function resolveSelfLaunchRuntime(protocol: SelfLaunchProtocol, protocolSessionId: string): RuntimeBinding | null;
|
|
28
|
+
export declare function unbindSelfLaunchRuntime(protocol: SelfLaunchProtocol, protocolSessionId: string, options?: BindingOptions): RuntimeBinding;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { LocalityError, requireLocalDatabasePath } from './locality.js';
|
|
2
|
+
export { resolveDatabasePath } from './path.js';
|
|
3
|
+
import { openRuntimeBindings, } from '@spexcode/session-runtime';
|
|
4
|
+
const SELF_LAUNCH_NAMESPACE = 'self-launch';
|
|
5
|
+
/**
|
|
6
|
+
* Attach a caller-owned native harness identity to an existing protocol address.
|
|
7
|
+
* This is the only self-launch/runtime seam; it does not launch, probe, or stop a harness.
|
|
8
|
+
*/
|
|
9
|
+
export function bindSelfLaunchRuntime(protocol, protocolSessionId, identity, options = {}) {
|
|
10
|
+
const bindings = openRuntimeBindings(protocol);
|
|
11
|
+
return protocol.withTransaction(tx => bindings.bind(tx, protocolSessionId, {
|
|
12
|
+
namespace: SELF_LAUNCH_NAMESPACE,
|
|
13
|
+
runtimeKind: options.runtimeKind ?? 'self-launch',
|
|
14
|
+
nativeSessionId: identity.nativeSessionId,
|
|
15
|
+
nativeStartToken: identity.nativeStartToken,
|
|
16
|
+
metadata: identity.metadata,
|
|
17
|
+
}, options));
|
|
18
|
+
}
|
|
19
|
+
export function resolveSelfLaunchRuntime(protocol, protocolSessionId) {
|
|
20
|
+
return openRuntimeBindings(protocol).resolve(SELF_LAUNCH_NAMESPACE, protocolSessionId);
|
|
21
|
+
}
|
|
22
|
+
export function unbindSelfLaunchRuntime(protocol, protocolSessionId, options = {}) {
|
|
23
|
+
const bindings = openRuntimeBindings(protocol);
|
|
24
|
+
return protocol.withTransaction(tx => bindings.unbind(tx, SELF_LAUNCH_NAMESPACE, protocolSessionId, options));
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type LocalityRefusalCode = 'LOCALITY_NETWORK_FILESYSTEM' | 'LOCALITY_UNDETERMINED' | 'LOCALITY_DETECTOR_UNAVAILABLE' | 'LOCALITY_PROBE_FAILED';
|
|
2
|
+
export declare class LocalityError extends Error {
|
|
3
|
+
readonly code: LocalityRefusalCode;
|
|
4
|
+
constructor(code: LocalityRefusalCode, message: string, cause?: unknown);
|
|
5
|
+
}
|
|
6
|
+
export interface FilesystemClassification {
|
|
7
|
+
readonly locality: 'local' | 'network' | 'undetermined';
|
|
8
|
+
readonly name: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function classifyFilesystemType(type: number | bigint): FilesystemClassification;
|
|
11
|
+
export interface LocalityDetector {
|
|
12
|
+
readonly platform: string;
|
|
13
|
+
statfsType(parentPath: string): number | bigint;
|
|
14
|
+
}
|
|
15
|
+
export declare function requireLocalDatabasePathWithDetector(databasePath: string, options: {
|
|
16
|
+
assumeLocal?: boolean;
|
|
17
|
+
}, detector: LocalityDetector): string;
|
|
18
|
+
export declare function requireLocalDatabasePath(databasePath: string, options?: {
|
|
19
|
+
assumeLocal?: boolean;
|
|
20
|
+
}): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { statfsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, dirname } from 'node:path';
|
|
3
|
+
import { DatabasePathError } from './path.js';
|
|
4
|
+
export class LocalityError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message, cause) {
|
|
7
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
8
|
+
this.name = 'LocalityError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// @@@network-magic-evidence - These header values drive classifier vectors, not real-mount evidence;
|
|
13
|
+
// this host has no corresponding network mount on which to run them.
|
|
14
|
+
const NETWORK_FILESYSTEM_TYPES = [
|
|
15
|
+
{ name: 'NFS', type: 0x6969 },
|
|
16
|
+
{ name: 'SMB', type: 0x517b },
|
|
17
|
+
{ name: 'CIFS', type: 0xff534d42 },
|
|
18
|
+
{ name: 'SMB2', type: 0xfe534d42 },
|
|
19
|
+
{ name: '9P', type: 0x01021997 },
|
|
20
|
+
{ name: 'CEPH', type: 0x00c36400 },
|
|
21
|
+
{ name: 'AFS', type: 0x5346414f },
|
|
22
|
+
{ name: 'AFS_FS', type: 0x6b414653 },
|
|
23
|
+
{ name: 'CODA', type: 0x73757245 },
|
|
24
|
+
{ name: 'OCFS2', type: 0x7461636f },
|
|
25
|
+
{ name: 'NCP', type: 0x564c },
|
|
26
|
+
];
|
|
27
|
+
const LOCAL_FILESYSTEM_TYPES = [
|
|
28
|
+
{ name: 'EXT2/3/4', type: 0xef53 },
|
|
29
|
+
{ name: 'BTRFS', type: 0x9123683e },
|
|
30
|
+
{ name: 'XFS', type: 0x58465342 },
|
|
31
|
+
{ name: 'F2FS', type: 0xf2f52010 },
|
|
32
|
+
{ name: 'TMPFS', type: 0x01021994 },
|
|
33
|
+
{ name: 'OVERLAYFS', type: 0x794c7630 },
|
|
34
|
+
{ name: 'ZFS', type: 0x2fc12fc1 },
|
|
35
|
+
];
|
|
36
|
+
const unsignedMagic = (type) => Number(BigInt.asUintN(32, BigInt(type)));
|
|
37
|
+
export function classifyFilesystemType(type) {
|
|
38
|
+
const magic = unsignedMagic(type);
|
|
39
|
+
const network = NETWORK_FILESYSTEM_TYPES.find(candidate => candidate.type === magic);
|
|
40
|
+
if (network)
|
|
41
|
+
return { locality: 'network', name: network.name };
|
|
42
|
+
const local = LOCAL_FILESYSTEM_TYPES.find(candidate => candidate.type === magic);
|
|
43
|
+
if (local)
|
|
44
|
+
return { locality: 'local', name: local.name };
|
|
45
|
+
return { locality: 'undetermined', name: `0x${magic.toString(16)}` };
|
|
46
|
+
}
|
|
47
|
+
export function requireLocalDatabasePathWithDetector(databasePath, options, detector) {
|
|
48
|
+
if (!isAbsolute(databasePath)) {
|
|
49
|
+
throw new DatabasePathError('PROTOCOL_PATH_NOT_ABSOLUTE', 'databasePath must be absolute before locality detection');
|
|
50
|
+
}
|
|
51
|
+
if (options.assumeLocal)
|
|
52
|
+
return databasePath;
|
|
53
|
+
const parent = dirname(databasePath);
|
|
54
|
+
if (detector.platform !== 'linux') {
|
|
55
|
+
throw new LocalityError('LOCALITY_DETECTOR_UNAVAILABLE', `no filesystem locality detector for platform ${detector.platform}; pass --assume-local-storage only after auditing ${parent}`);
|
|
56
|
+
}
|
|
57
|
+
let type;
|
|
58
|
+
try {
|
|
59
|
+
type = detector.statfsType(parent);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// @@@missing-parent - Preserve the actionable path error without pretending locality was established.
|
|
63
|
+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
|
|
64
|
+
throw new DatabasePathError('PROTOCOL_PATH_PARENT_MISSING', `database parent directory does not exist: ${parent}`, error);
|
|
65
|
+
}
|
|
66
|
+
throw new LocalityError('LOCALITY_PROBE_FAILED', `could not determine the filesystem of ${parent}`, error);
|
|
67
|
+
}
|
|
68
|
+
const classification = classifyFilesystemType(type);
|
|
69
|
+
if (classification.locality === 'network') {
|
|
70
|
+
throw new LocalityError('LOCALITY_NETWORK_FILESYSTEM', `${parent} is ${classification.name}; advisory locking is not admitted there`);
|
|
71
|
+
}
|
|
72
|
+
if (classification.locality === 'undetermined') {
|
|
73
|
+
throw new LocalityError('LOCALITY_UNDETERMINED', `filesystem type ${classification.name} at ${parent} is not on the audited local allow-list`);
|
|
74
|
+
}
|
|
75
|
+
return databasePath;
|
|
76
|
+
}
|
|
77
|
+
const linuxDetector = {
|
|
78
|
+
platform: process.platform,
|
|
79
|
+
statfsType: parentPath => statfsSync(parentPath).type,
|
|
80
|
+
};
|
|
81
|
+
export function requireLocalDatabasePath(databasePath, options = {}) {
|
|
82
|
+
return requireLocalDatabasePathWithDetector(databasePath, options, linuxDetector);
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type DatabasePathErrorCode = 'PROTOCOL_PATH_NOT_ABSOLUTE' | 'PROTOCOL_PATH_INVALID' | 'PROTOCOL_PATH_PARENT_MISSING';
|
|
2
|
+
export declare class DatabasePathError extends Error {
|
|
3
|
+
readonly code: DatabasePathErrorCode;
|
|
4
|
+
constructor(code: DatabasePathErrorCode, message: string, cause?: unknown);
|
|
5
|
+
}
|
|
6
|
+
export type SelfLaunchEnvironment = Readonly<Record<string, string | undefined>>;
|
|
7
|
+
export interface ResolveDatabasePathOptions {
|
|
8
|
+
databasePath?: string;
|
|
9
|
+
env?: SelfLaunchEnvironment;
|
|
10
|
+
readFile?: (path: string) => string;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveDatabasePath(options?: ResolveDatabasePathOptions): string;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
export class DatabasePathError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message, cause) {
|
|
6
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
7
|
+
this.name = 'DatabasePathError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function resolveDatabasePath(options = {}) {
|
|
12
|
+
const env = options.env ?? process.env;
|
|
13
|
+
let databasePath = options.databasePath ?? env.SPEX_SESSION_DATABASE_PATH;
|
|
14
|
+
if (databasePath === undefined && env.SPEX_SESSION_CONFIG) {
|
|
15
|
+
const configPath = env.SPEX_SESSION_CONFIG;
|
|
16
|
+
let config;
|
|
17
|
+
try {
|
|
18
|
+
config = JSON.parse((options.readFile ?? ((path) => readFileSync(path, 'utf8')))(configPath));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new DatabasePathError('PROTOCOL_PATH_INVALID', `could not read session config ${configPath}`, error);
|
|
22
|
+
}
|
|
23
|
+
if (typeof config !== 'object'
|
|
24
|
+
|| config === null
|
|
25
|
+
|| typeof config.databasePath !== 'string'
|
|
26
|
+
|| !config.databasePath) {
|
|
27
|
+
throw new DatabasePathError('PROTOCOL_PATH_INVALID', `session config ${configPath} must contain databasePath`);
|
|
28
|
+
}
|
|
29
|
+
databasePath = config.databasePath;
|
|
30
|
+
}
|
|
31
|
+
if (databasePath === undefined) {
|
|
32
|
+
const home = env.SPEXCODE_HOME || (env.HOME ? join(env.HOME, '.spexcode') : undefined);
|
|
33
|
+
if (!home) {
|
|
34
|
+
throw new DatabasePathError('PROTOCOL_PATH_INVALID', 'HOME or SPEXCODE_HOME is required for the default database path');
|
|
35
|
+
}
|
|
36
|
+
databasePath = join(home, 'sessions.sqlite');
|
|
37
|
+
}
|
|
38
|
+
if (databasePath.length === 0) {
|
|
39
|
+
throw new DatabasePathError('PROTOCOL_PATH_INVALID', 'databasePath must not be empty');
|
|
40
|
+
}
|
|
41
|
+
if (!isAbsolute(databasePath)) {
|
|
42
|
+
throw new DatabasePathError('PROTOCOL_PATH_NOT_ABSOLUTE', 'databasePath must be absolute and is never resolved from cwd');
|
|
43
|
+
}
|
|
44
|
+
return databasePath;
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spexcode/session-selflaunch",
|
|
3
|
+
"version": "0.6.8",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "A fail-closed self-launch adopter for the SpexCode session protocol.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"spex-session": "./bin/spex-session.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"bin"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./dist/index.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node ../../scripts/build-dist.mjs",
|
|
23
|
+
"prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
|
|
24
|
+
"test": "npm run build && tsx --import ../../scripts/test-home.mjs --test src/*.test.ts"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@spexcode/session-protocol": "0.6.8",
|
|
28
|
+
"@spexcode/session-runtime": "0.6.8"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.16.0",
|
|
32
|
+
"tsx": "^4.19.2",
|
|
33
|
+
"typescript": "^5.6.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type TopologyErrorCode = 'TOPOLOGY_EDGE_ID_INVALID' | 'TOPOLOGY_EDGE_UNKNOWN' | 'TOPOLOGY_EDGE_EXISTS' | 'TOPOLOGY_CYCLE_REFUSED' | 'TOPOLOGY_RELATION_TYPE_INVALID' | 'TOPOLOGY_SESSION_ID_INVALID' | 'TOPOLOGY_SESSION_UNKNOWN' | 'TOPOLOGY_SELF_EDGE' | 'TOPOLOGY_TRANSACTION_INVALID' | 'TOPOLOGY_STORAGE_ERROR';
|
|
2
|
+
export declare class TopologyError extends Error {
|
|
3
|
+
readonly code: TopologyErrorCode;
|
|
4
|
+
constructor(code: TopologyErrorCode, message: string, cause?: unknown);
|
|
5
|
+
}
|
|
6
|
+
export declare function failTopology(code: TopologyErrorCode, message: string, cause?: unknown): never;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class TopologyError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message, cause) {
|
|
4
|
+
super(message, { cause });
|
|
5
|
+
this.name = 'TopologyError';
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function failTopology(code, message, cause) {
|
|
10
|
+
throw new TopologyError(code, message, cause);
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ProtocolTransaction, SessionProtocol } from '@spexcode/session-protocol';
|
|
2
|
+
import { TopologyError } from './errors.js';
|
|
3
|
+
export interface TopologyEdge {
|
|
4
|
+
edgeId: string;
|
|
5
|
+
fromSessionId: string;
|
|
6
|
+
toSessionId: string;
|
|
7
|
+
relationType: string;
|
|
8
|
+
createdAtMs: number;
|
|
9
|
+
removedAtMs: number | null;
|
|
10
|
+
}
|
|
11
|
+
export interface SessionTopology {
|
|
12
|
+
attach(tx: ProtocolTransaction, fromSessionId: string, toSessionId: string, relationType: string): TopologyEdge;
|
|
13
|
+
detach(tx: ProtocolTransaction, edgeId: string): TopologyEdge;
|
|
14
|
+
reparent(tx: ProtocolTransaction, subjectSessionId: string, nextFromSessionId: string, relationType: string): TopologyEdge;
|
|
15
|
+
subscribe(tx: ProtocolTransaction, watcherSessionId: string, subjectSessionId: string, channel: string): TopologyEdge;
|
|
16
|
+
unsubscribe(tx: ProtocolTransaction, watcherSessionId: string, subjectSessionId: string, channel: string): TopologyEdge;
|
|
17
|
+
parents(sessionId: string, relationType?: string, tx?: ProtocolTransaction): TopologyEdge[];
|
|
18
|
+
children(sessionId: string, relationType?: string, tx?: ProtocolTransaction): TopologyEdge[];
|
|
19
|
+
subscriptions(sessionId: string, tx?: ProtocolTransaction): TopologyEdge[];
|
|
20
|
+
recipients(subjectSessionId: string, tx?: ProtocolTransaction): string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function openTopology(protocol: SessionProtocol): SessionTopology;
|
|
23
|
+
export { TopologyError };
|
|
24
|
+
export type { TopologyErrorCode } from './errors.js';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { applyComponentMigrations } from '@spexcode/session-protocol';
|
|
3
|
+
import { failTopology, TopologyError } from './errors.js';
|
|
4
|
+
import { TOPOLOGY_MIGRATIONS } from './schema.js';
|
|
5
|
+
const EDGE_ID = /^[0-9a-f]{32}$/;
|
|
6
|
+
const RELATION_TYPE = /^[0-9A-Za-z._:-]{1,64}$/;
|
|
7
|
+
const SESSION_ID = /^(?!-)[0-9A-Za-z_-]{1,256}$/;
|
|
8
|
+
const EDGE_COLUMNS = `edge_id, from_session_id, to_session_id, relation_type, created_at_ms, removed_at_ms`;
|
|
9
|
+
export function openTopology(protocol) {
|
|
10
|
+
applyComponentMigrations(protocol, 'session-topology', TOPOLOGY_MIGRATIONS);
|
|
11
|
+
const requireTransaction = (tx) => {
|
|
12
|
+
if (!tx
|
|
13
|
+
|| typeof tx.exec !== 'function'
|
|
14
|
+
|| typeof tx.query !== 'function'
|
|
15
|
+
|| typeof tx.enqueue !== 'function') {
|
|
16
|
+
failTopology('TOPOLOGY_TRANSACTION_INVALID', 'a live protocol transaction context is required');
|
|
17
|
+
}
|
|
18
|
+
return tx;
|
|
19
|
+
};
|
|
20
|
+
const requireSessionId = (sessionId) => {
|
|
21
|
+
if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) {
|
|
22
|
+
failTopology('TOPOLOGY_SESSION_ID_INVALID', 'session id must match the protocol address grammar');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const requireRelationType = (relationType) => {
|
|
26
|
+
if (typeof relationType !== 'string' || !RELATION_TYPE.test(relationType)) {
|
|
27
|
+
failTopology('TOPOLOGY_RELATION_TYPE_INVALID', 'relation type must match [0-9A-Za-z._:-]{1,64}');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const requireEdgeId = (edgeId) => {
|
|
31
|
+
if (typeof edgeId !== 'string' || !EDGE_ID.test(edgeId)) {
|
|
32
|
+
failTopology('TOPOLOGY_EDGE_ID_INVALID', 'edge id must be 32 lowercase hexadecimal characters');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const toEdge = (row) => ({
|
|
36
|
+
edgeId: String(row.edge_id),
|
|
37
|
+
fromSessionId: String(row.from_session_id),
|
|
38
|
+
toSessionId: String(row.to_session_id),
|
|
39
|
+
relationType: String(row.relation_type),
|
|
40
|
+
createdAtMs: Number(row.created_at_ms),
|
|
41
|
+
removedAtMs: row.removed_at_ms === null ? null : Number(row.removed_at_ms),
|
|
42
|
+
});
|
|
43
|
+
const asEdgeRows = (rows) => rows;
|
|
44
|
+
const requireAddresses = (tx, fromSessionId, toSessionId) => {
|
|
45
|
+
const rows = tx.query('SELECT session_id FROM protocol_sessions WHERE session_id IN (?,?) ORDER BY session_id', fromSessionId, toSessionId);
|
|
46
|
+
const found = new Set(rows.map(row => String(row.session_id)));
|
|
47
|
+
if (!found.has(fromSessionId) || !found.has(toSessionId)) {
|
|
48
|
+
failTopology('TOPOLOGY_SESSION_UNKNOWN', 'one or more topology session addresses are unknown');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const cycleExists = (tx, fromSessionId, toSessionId, relationType) => tx.query(`WITH RECURSIVE reachable(session_id) AS (
|
|
52
|
+
VALUES (?)
|
|
53
|
+
UNION
|
|
54
|
+
SELECT edge.to_session_id
|
|
55
|
+
FROM topology_edges AS edge INDEXED BY topology_active_edge
|
|
56
|
+
JOIN reachable ON edge.from_session_id=reachable.session_id
|
|
57
|
+
WHERE edge.relation_type=? AND edge.removed_at_ms IS NULL
|
|
58
|
+
)
|
|
59
|
+
SELECT 1 AS present FROM reachable WHERE session_id=? LIMIT 1`, toSessionId, relationType, fromSessionId).length > 0;
|
|
60
|
+
const validateNewEdge = (tx, fromSessionId, toSessionId, relationType) => {
|
|
61
|
+
requireTransaction(tx);
|
|
62
|
+
requireSessionId(fromSessionId);
|
|
63
|
+
requireSessionId(toSessionId);
|
|
64
|
+
requireRelationType(relationType);
|
|
65
|
+
if (fromSessionId === toSessionId) {
|
|
66
|
+
failTopology('TOPOLOGY_SELF_EDGE', 'an edge cannot point from a session to itself');
|
|
67
|
+
}
|
|
68
|
+
requireAddresses(tx, fromSessionId, toSessionId);
|
|
69
|
+
if (cycleExists(tx, fromSessionId, toSessionId, relationType)) {
|
|
70
|
+
failTopology('TOPOLOGY_CYCLE_REFUSED', 'the edge would create a relation cycle');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const activeEdge = (tx, fromSessionId, toSessionId, relationType) => asEdgeRows(tx.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges INDEXED BY topology_active_edge
|
|
74
|
+
WHERE from_session_id=? AND to_session_id=? AND relation_type=? AND removed_at_ms IS NULL`, fromSessionId, toSessionId, relationType))[0];
|
|
75
|
+
const insertEdge = (tx, fromSessionId, toSessionId, relationType) => {
|
|
76
|
+
const edgeId = randomBytes(16).toString('hex');
|
|
77
|
+
const createdAtMs = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
tx.exec(`INSERT INTO topology_edges
|
|
80
|
+
(edge_id, from_session_id, to_session_id, relation_type, created_at_ms, removed_at_ms)
|
|
81
|
+
VALUES (?,?,?,?,?,NULL)`, edgeId, fromSessionId, toSessionId, relationType, createdAtMs);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const code = String(error?.code ?? '');
|
|
85
|
+
if (code.includes('UNIQUE') || code.includes('PRIMARYKEY')) {
|
|
86
|
+
failTopology('TOPOLOGY_EDGE_EXISTS', 'the active topology edge already exists', error);
|
|
87
|
+
}
|
|
88
|
+
if (code.includes('FOREIGNKEY')) {
|
|
89
|
+
failTopology('TOPOLOGY_SESSION_UNKNOWN', 'one or more topology session addresses are unknown', error);
|
|
90
|
+
}
|
|
91
|
+
failTopology('TOPOLOGY_STORAGE_ERROR', 'the topology edge could not be stored', error);
|
|
92
|
+
}
|
|
93
|
+
return { edgeId, fromSessionId, toSessionId, relationType, createdAtMs, removedAtMs: null };
|
|
94
|
+
};
|
|
95
|
+
const attach = (tx, fromSessionId, toSessionId, relationType) => {
|
|
96
|
+
validateNewEdge(tx, fromSessionId, toSessionId, relationType);
|
|
97
|
+
if (activeEdge(tx, fromSessionId, toSessionId, relationType)) {
|
|
98
|
+
failTopology('TOPOLOGY_EDGE_EXISTS', 'the active topology edge already exists');
|
|
99
|
+
}
|
|
100
|
+
return insertEdge(tx, fromSessionId, toSessionId, relationType);
|
|
101
|
+
};
|
|
102
|
+
const detach = (tx, edgeId) => {
|
|
103
|
+
requireTransaction(tx);
|
|
104
|
+
requireEdgeId(edgeId);
|
|
105
|
+
const row = asEdgeRows(tx.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges WHERE edge_id=? AND removed_at_ms IS NULL`, edgeId))[0];
|
|
106
|
+
if (!row)
|
|
107
|
+
failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
|
|
108
|
+
const removedAtMs = Date.now();
|
|
109
|
+
const result = tx.exec('UPDATE topology_edges SET removed_at_ms=? WHERE edge_id=? AND removed_at_ms IS NULL', removedAtMs, edgeId);
|
|
110
|
+
if (result.changes !== 1) {
|
|
111
|
+
failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
|
|
112
|
+
}
|
|
113
|
+
return { ...toEdge(row), removedAtMs };
|
|
114
|
+
};
|
|
115
|
+
const withRead = (tx, query) => {
|
|
116
|
+
if (tx !== undefined)
|
|
117
|
+
return query(requireTransaction(tx));
|
|
118
|
+
return protocol.withTransaction(query);
|
|
119
|
+
};
|
|
120
|
+
const queryEdges = (sessionId, relationType, tx, direction) => {
|
|
121
|
+
requireSessionId(sessionId);
|
|
122
|
+
if (relationType !== undefined)
|
|
123
|
+
requireRelationType(relationType);
|
|
124
|
+
return withRead(tx, active => {
|
|
125
|
+
const index = direction === 'from' ? 'topology_active_edge' : 'topology_active_to';
|
|
126
|
+
const column = direction === 'from' ? 'from_session_id' : 'to_session_id';
|
|
127
|
+
const relation = relationType === undefined ? '' : ' AND relation_type=?';
|
|
128
|
+
const params = relationType === undefined ? [sessionId] : [sessionId, relationType];
|
|
129
|
+
return asEdgeRows(active.query(`SELECT ${EDGE_COLUMNS} FROM topology_edges INDEXED BY ${index}
|
|
130
|
+
WHERE ${column}=? AND removed_at_ms IS NULL${relation}
|
|
131
|
+
ORDER BY relation_type, from_session_id, to_session_id, created_at_ms, edge_id`, ...params)).map(toEdge);
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
attach,
|
|
136
|
+
detach,
|
|
137
|
+
reparent(tx, subjectSessionId, nextFromSessionId, relationType) {
|
|
138
|
+
validateNewEdge(tx, nextFromSessionId, subjectSessionId, relationType);
|
|
139
|
+
const removedAtMs = Date.now();
|
|
140
|
+
tx.exec(`UPDATE topology_edges SET removed_at_ms=?
|
|
141
|
+
WHERE to_session_id=? AND relation_type=? AND removed_at_ms IS NULL`, removedAtMs, subjectSessionId, relationType);
|
|
142
|
+
return insertEdge(tx, nextFromSessionId, subjectSessionId, relationType);
|
|
143
|
+
},
|
|
144
|
+
subscribe(tx, watcherSessionId, subjectSessionId, channel) {
|
|
145
|
+
return attach(tx, watcherSessionId, subjectSessionId, channel);
|
|
146
|
+
},
|
|
147
|
+
unsubscribe(tx, watcherSessionId, subjectSessionId, channel) {
|
|
148
|
+
requireTransaction(tx);
|
|
149
|
+
requireSessionId(watcherSessionId);
|
|
150
|
+
requireSessionId(subjectSessionId);
|
|
151
|
+
requireRelationType(channel);
|
|
152
|
+
const row = activeEdge(tx, watcherSessionId, subjectSessionId, channel);
|
|
153
|
+
if (!row)
|
|
154
|
+
failTopology('TOPOLOGY_EDGE_UNKNOWN', 'the active topology edge does not exist');
|
|
155
|
+
return detach(tx, String(row.edge_id));
|
|
156
|
+
},
|
|
157
|
+
parents(sessionId, relationType, tx) {
|
|
158
|
+
return queryEdges(sessionId, relationType, tx, 'to');
|
|
159
|
+
},
|
|
160
|
+
children(sessionId, relationType, tx) {
|
|
161
|
+
return queryEdges(sessionId, relationType, tx, 'from');
|
|
162
|
+
},
|
|
163
|
+
subscriptions(sessionId, tx) {
|
|
164
|
+
return queryEdges(sessionId, undefined, tx, 'from');
|
|
165
|
+
},
|
|
166
|
+
recipients(subjectSessionId, tx) {
|
|
167
|
+
requireSessionId(subjectSessionId);
|
|
168
|
+
return withRead(tx, active => active.query(`SELECT DISTINCT from_session_id FROM topology_edges INDEXED BY topology_active_to
|
|
169
|
+
WHERE to_session_id=? AND removed_at_ms IS NULL ORDER BY from_session_id`, subjectSessionId).map(row => String(row.from_session_id)));
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export { TopologyError };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ComponentMigration } from '@spexcode/session-protocol';
|
|
2
|
+
export declare const TOPOLOGY_MIGRATION_SQL = "\nCREATE TABLE topology_edges (\n edge_id TEXT NOT NULL PRIMARY KEY,\n from_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),\n to_session_id TEXT NOT NULL REFERENCES protocol_sessions(session_id),\n relation_type TEXT NOT NULL,\n created_at_ms INTEGER NOT NULL,\n removed_at_ms INTEGER,\n CHECK (length(edge_id) = 32 AND edge_id NOT GLOB '*[^0-9a-f]*'),\n CHECK (from_session_id <> to_session_id),\n CHECK (length(relation_type) BETWEEN 1 AND 64),\n CHECK (relation_type NOT GLOB '*[^0-9A-Za-z._:-]*'),\n CHECK (created_at_ms >= 0),\n CHECK (removed_at_ms IS NULL OR removed_at_ms >= 0)\n) STRICT;\n\nCREATE UNIQUE INDEX topology_active_edge\n ON topology_edges (from_session_id, to_session_id, relation_type)\n WHERE removed_at_ms IS NULL;\n\nCREATE INDEX topology_active_to\n ON topology_edges (to_session_id, relation_type, from_session_id)\n WHERE removed_at_ms IS NULL;\n";
|
|
3
|
+
export declare const TOPOLOGY_MIGRATIONS: readonly ComponentMigration[];
|