@gregiteen/ssss-cli 0.8.0 → 0.9.1
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/CHANGELOG.md +55 -4
- package/README.md +42 -84
- package/VERSION +1 -1
- package/conformance/README.md +1 -1
- package/conformance/fixtures.json +4 -48
- package/conformance/reference-bundle.ucw.json +22 -21
- package/docs/help/bundle.md +2 -2
- package/docs/help/overview.md +9 -6
- package/docs/help/semantic.md +5 -4
- package/docs/ssss-spec.md +42 -86
- package/package.json +22 -3
- package/registry/core.json +22 -29
- package/scripts/build-reference-bundle.mjs +0 -20
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-import.mjs +1 -1
- package/scripts/cmd-migrate.mjs +26 -0
- package/scripts/cmd-new.mjs +5 -5
- package/scripts/cmd-primitive.mjs +48 -0
- package/scripts/cmd-registry.mjs +25 -0
- package/scripts/cmd-semantic.mjs +6 -8
- package/scripts/conformance-09.mjs +331 -0
- package/scripts/conformance.mjs +153 -186
- package/scripts/generate-sbom.mjs +96 -0
- package/scripts/phase9-cross-host-corpus.mjs +223 -0
- package/scripts/ssss.mjs +8 -2
- package/skills/ssss/SKILL.md +32 -208
- package/skills/ssss/evals/evals.json +3 -3
- package/skills/ssss/references/contract-reference.md +7 -8
- package/skills/ssss/scripts/validate-schema.mjs +1 -44
- package/src/authorization.mjs +62 -0
- package/src/bundle.mjs +41 -5
- package/src/engine.mjs +101 -503
- package/src/events.mjs +155 -0
- package/src/guard.mjs +32 -0
- package/src/http.mjs +26 -0
- package/src/idempotency.mjs +55 -0
- package/src/kernel.mjs +260 -0
- package/src/leases.mjs +173 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +133 -0
- package/src/registry.mjs +190 -39
- package/src/semantic.mjs +124 -316
- package/src/ui.mjs +100 -0
- package/src/validator.mjs +87 -0
- package/src/vfs.mjs +203 -0
- package/types/kernel.d.ts +48 -0
- package/docs/help/localization.md +0 -43
- package/scripts/cmd-localize.mjs +0 -44
package/src/events.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Canonical immutable SSSS event envelope and stores. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export function createCanonicalEvent(input, options = {}) {
|
|
7
|
+
const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
|
|
8
|
+
for (const field of required) if (typeof input?.[field] !== 'string' || !input[field]) throw new Error(`Event ${field} is required.`);
|
|
9
|
+
if (!input.principal?.id || !input.principal?.kind) throw new Error('Event verified principal is required.');
|
|
10
|
+
return {
|
|
11
|
+
event_id: input.event_id || crypto.randomUUID(),
|
|
12
|
+
event_type: input.event_type || 'ssss.mutation',
|
|
13
|
+
schema_version: 1,
|
|
14
|
+
timestamp: input.timestamp || (options.clock ? options.clock() : new Date().toISOString()),
|
|
15
|
+
workspace_id: input.workspace_id,
|
|
16
|
+
primitive_id: input.primitive_id || null,
|
|
17
|
+
primitive_version: input.primitive_version || 1,
|
|
18
|
+
action: input.action,
|
|
19
|
+
subject: input.subject,
|
|
20
|
+
principal: input.principal,
|
|
21
|
+
correlation_id: input.correlation_id || input.operation_id,
|
|
22
|
+
causation_id: input.causation_id || null,
|
|
23
|
+
operation_id: input.operation_id,
|
|
24
|
+
idempotency_key: input.idempotency_key,
|
|
25
|
+
request_hash: input.request_hash || null,
|
|
26
|
+
before_hash: input.before_hash || null,
|
|
27
|
+
after_hash: input.after_hash || null,
|
|
28
|
+
changed_fields: input.changed_fields || [],
|
|
29
|
+
resource_status: input.resource_status || null,
|
|
30
|
+
payload: input.payload || null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class MemoryEventStore {
|
|
35
|
+
#events = [];
|
|
36
|
+
#ids = new Set();
|
|
37
|
+
async append(event) {
|
|
38
|
+
if (this.#ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
39
|
+
const frozen = structuredClone(event);
|
|
40
|
+
this.#events.push(frozen);
|
|
41
|
+
this.#ids.add(frozen.event_id);
|
|
42
|
+
return structuredClone(frozen);
|
|
43
|
+
}
|
|
44
|
+
async *replay(options = {}) {
|
|
45
|
+
for (let index = options.cursor || 0; index < this.#events.length; index++) {
|
|
46
|
+
const event = this.#events[index];
|
|
47
|
+
if (options.workspaceId && event.workspace_id !== options.workspaceId) continue;
|
|
48
|
+
yield { cursor: index + 1, event: structuredClone(event) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async size() { return this.#events.length; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
55
|
+
const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | NOFOLLOW;
|
|
56
|
+
|
|
57
|
+
function openLog(file, flags, mode) {
|
|
58
|
+
try { return fs.openSync(file, flags, mode); }
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error.code === 'ELOOP') throw new Error('Symlinked event logs are forbidden.');
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sameStat(a, b) {
|
|
66
|
+
return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read every event_id in a log, bounded to the bytes covered by the returned stat. */
|
|
70
|
+
function loadIndex(file) {
|
|
71
|
+
const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
72
|
+
try {
|
|
73
|
+
const stat = fs.fstatSync(fd);
|
|
74
|
+
const bytes = Buffer.alloc(stat.size);
|
|
75
|
+
let offset = 0;
|
|
76
|
+
while (offset < bytes.length) {
|
|
77
|
+
const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
78
|
+
if (read === 0) break;
|
|
79
|
+
offset += read;
|
|
80
|
+
}
|
|
81
|
+
const ids = new Set();
|
|
82
|
+
for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
|
|
83
|
+
if (line.trim()) ids.add(JSON.parse(line).event_id);
|
|
84
|
+
}
|
|
85
|
+
return { ids, stat };
|
|
86
|
+
} finally { fs.closeSync(fd); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class JsonlEventStore {
|
|
90
|
+
// Per-log index of event_ids, built on the first append to that log. Each
|
|
91
|
+
// index records the stat of the bytes it covers; any change (another store
|
|
92
|
+
// instance or process appended, or the file was replaced) rebuilds it.
|
|
93
|
+
#indexes = new Map();
|
|
94
|
+
constructor(root) {
|
|
95
|
+
fs.mkdirSync(root, { recursive: true });
|
|
96
|
+
this.root = fs.realpathSync(root);
|
|
97
|
+
}
|
|
98
|
+
#file(workspaceId) {
|
|
99
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
|
|
100
|
+
return path.join(this.root, `${workspaceId}.jsonl`);
|
|
101
|
+
}
|
|
102
|
+
#index(file) {
|
|
103
|
+
let stat;
|
|
104
|
+
try { stat = fs.lstatSync(file); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== 'ENOENT') throw error;
|
|
107
|
+
this.#indexes.delete(file);
|
|
108
|
+
return { ids: new Set(), stat: null };
|
|
109
|
+
}
|
|
110
|
+
if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
|
|
111
|
+
const cached = this.#indexes.get(file);
|
|
112
|
+
if (cached && sameStat(cached.stat, stat)) return cached;
|
|
113
|
+
const index = loadIndex(file);
|
|
114
|
+
this.#indexes.set(file, index);
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
117
|
+
async append(event) {
|
|
118
|
+
const file = this.#file(event.workspace_id);
|
|
119
|
+
const index = this.#index(file);
|
|
120
|
+
if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
|
|
121
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
122
|
+
this.#indexes.delete(file);
|
|
123
|
+
const fd = openLog(file, APPEND_FLAGS, 0o600);
|
|
124
|
+
let after;
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(fd, line);
|
|
127
|
+
after = fs.fstatSync(fd);
|
|
128
|
+
} finally { fs.closeSync(fd); }
|
|
129
|
+
// Keep the index only when the log grew by exactly this line on the same
|
|
130
|
+
// file; otherwise another writer interleaved and the next append rebuilds.
|
|
131
|
+
const before = index.stat;
|
|
132
|
+
const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
|
|
133
|
+
if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
|
|
134
|
+
index.ids.add(event.event_id);
|
|
135
|
+
this.#indexes.set(file, { ids: index.ids, stat: after });
|
|
136
|
+
}
|
|
137
|
+
return structuredClone(event);
|
|
138
|
+
}
|
|
139
|
+
async *replay(options = {}) {
|
|
140
|
+
const files = options.workspaceId
|
|
141
|
+
? [this.#file(options.workspaceId)]
|
|
142
|
+
: fs.readdirSync(this.root).filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(this.root, name));
|
|
143
|
+
let cursor = 0;
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
if (!fs.existsSync(file)) continue;
|
|
146
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
|
|
147
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
148
|
+
if (!line.trim()) continue;
|
|
149
|
+
cursor++;
|
|
150
|
+
if (cursor <= (options.cursor || 0)) continue;
|
|
151
|
+
yield { cursor, event: JSON.parse(line) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Static guard for likely direct canonical writes outside approved SSSS adapters. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const WRITE_PATTERNS = [
|
|
6
|
+
/\bfs\.(?:writeFile|writeFileSync|appendFile|appendFileSync|rm|rmSync|unlink|unlinkSync|rename|renameSync)\s*\(/,
|
|
7
|
+
/\b(?:writeFile|appendFile|unlink|rename)\s*\(/,
|
|
8
|
+
/\b(?:insert|update|delete)\s*\([^)]*\)\s*\.?(?:values|set|where)?\s*\(/,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function detectDirectWrites(root, options = {}) {
|
|
12
|
+
const approved = (options.approved || []).map((entry) => entry.replaceAll('\\', '/'));
|
|
13
|
+
const include = options.include || /\.(?:[cm]?[jt]sx?)$/;
|
|
14
|
+
const findings = [];
|
|
15
|
+
const stack = [path.resolve(root)];
|
|
16
|
+
while (stack.length) {
|
|
17
|
+
const dir = stack.pop();
|
|
18
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
|
|
20
|
+
const absolute = path.join(dir, entry.name);
|
|
21
|
+
if (entry.isDirectory()) { stack.push(absolute); continue; }
|
|
22
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
23
|
+
if (!include.test(relative) || approved.some((prefix) => relative === prefix || relative.startsWith(`${prefix}/`))) continue;
|
|
24
|
+
const lines = fs.readFileSync(absolute, 'utf8').split('\n');
|
|
25
|
+
for (const [index, line] of lines.entries()) {
|
|
26
|
+
if (line.includes('ssss-direct-write-approved')) continue;
|
|
27
|
+
if (WRITE_PATTERNS.some((pattern) => pattern.test(line))) findings.push({ file: relative, line: index + 1, source: line.trim() });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return findings;
|
|
32
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Reference transport adapters. HTTP is a façade over kernel.execute, never a writer. */
|
|
2
|
+
|
|
3
|
+
export function createCommandHandler({ kernel, authenticate, parseBody = async (request) => request.json() }) {
|
|
4
|
+
if (!kernel?.execute || typeof authenticate !== 'function') throw new Error('kernel and authenticate(request) are required.');
|
|
5
|
+
return async function handle(request) {
|
|
6
|
+
try {
|
|
7
|
+
const principal = await authenticate(request);
|
|
8
|
+
const envelope = await parseBody(request);
|
|
9
|
+
const response = await kernel.execute(envelope, { principal, request });
|
|
10
|
+
const status = response.success ? (response.replay ? 200 : response.dry_run ? 200 : 201) :
|
|
11
|
+
response.validation?.errors?.some((error) => error.includes('Access denied')) ? 403 :
|
|
12
|
+
response.validation?.errors?.some((error) => error.includes('Idempotency')) ? 409 : 422;
|
|
13
|
+
return { status, headers: { 'content-type': 'application/json' }, body: response };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return { status: 400, headers: { 'content-type': 'application/json' }, body: { success: false, error: { code: 'invalid_request', message: error.message } } };
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDomainCommand({ kernel, buildEnvelope }) {
|
|
21
|
+
if (!kernel?.execute || typeof buildEnvelope !== 'function') throw new Error('kernel and buildEnvelope(input) are required.');
|
|
22
|
+
return async function domainCommand(input, context) {
|
|
23
|
+
const envelope = await buildEnvelope(input, context);
|
|
24
|
+
return kernel.execute(envelope, context);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Durable and in-memory idempotency store contracts. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
|
|
7
|
+
|
|
8
|
+
export class MemoryIdempotencyStore {
|
|
9
|
+
#entries = new Map();
|
|
10
|
+
async get(workspaceId, idempotencyKey) { return structuredClone(this.#entries.get(key(workspaceId, idempotencyKey)) || null); }
|
|
11
|
+
async put(workspaceId, idempotencyKey, value) {
|
|
12
|
+
const id = key(workspaceId, idempotencyKey);
|
|
13
|
+
if (this.#entries.has(id)) throw new Error('Idempotency entry already exists.');
|
|
14
|
+
this.#entries.set(id, structuredClone(value));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class FileIdempotencyStore {
|
|
19
|
+
constructor(root) { fs.mkdirSync(root, { recursive: true }); this.root = fs.realpathSync(root); }
|
|
20
|
+
#file(workspaceId, idempotencyKey) {
|
|
21
|
+
const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
|
|
22
|
+
return path.join(this.root, `${digest}.json`);
|
|
23
|
+
}
|
|
24
|
+
async get(workspaceId, idempotencyKey) {
|
|
25
|
+
const file = this.#file(workspaceId, idempotencyKey);
|
|
26
|
+
if (!fs.existsSync(file)) return null;
|
|
27
|
+
const stat = fs.lstatSync(file);
|
|
28
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('Idempotency state is unreadable.');
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
30
|
+
catch { throw new Error('Idempotency state is unreadable.'); }
|
|
31
|
+
}
|
|
32
|
+
async put(workspaceId, idempotencyKey, value) {
|
|
33
|
+
const file = this.#file(workspaceId, idempotencyKey);
|
|
34
|
+
if (fs.existsSync(file)) throw new Error('Idempotency entry already exists.');
|
|
35
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
|
|
38
|
+
fs.renameSync(temp, file);
|
|
39
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runIdempotencyContract(store) {
|
|
44
|
+
const workspace = `ws-${crypto.randomUUID()}`;
|
|
45
|
+
const id = `key-${crypto.randomUUID()}`;
|
|
46
|
+
const checks = [];
|
|
47
|
+
const record = (name, passed) => checks.push({ name, passed: !!passed });
|
|
48
|
+
record('missing key returns null', await store.get(workspace, id) === null);
|
|
49
|
+
await store.put(workspace, id, { request_hash: 'sha256:test', response: { success: true } });
|
|
50
|
+
record('stored result round-trips', (await store.get(workspace, id))?.request_hash === 'sha256:test');
|
|
51
|
+
let duplicate = false;
|
|
52
|
+
try { await store.put(workspace, id, { request_hash: 'different' }); } catch { duplicate = true; }
|
|
53
|
+
record('duplicate put is rejected', duplicate);
|
|
54
|
+
return { passed: checks.every((check) => check.passed), checks };
|
|
55
|
+
}
|
package/src/kernel.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSSS 0.9 shared mutation kernel.
|
|
3
|
+
* Hosts inject infrastructure adapters; this module owns ordering and semantics.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
7
|
+
import { createValidator } from './validator.mjs';
|
|
8
|
+
import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
|
|
9
|
+
import { createCapabilityAuthorizer } from './authorization.mjs';
|
|
10
|
+
import { createCanonicalEvent } from './events.mjs';
|
|
11
|
+
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
12
|
+
|
|
13
|
+
export { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
14
|
+
|
|
15
|
+
const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
|
|
16
|
+
|
|
17
|
+
function stable(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
19
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function canonicalRequestHash(envelope, principal) {
|
|
24
|
+
const clean = {
|
|
25
|
+
type: envelope?.type,
|
|
26
|
+
workspace_id: envelope?.workspace_id,
|
|
27
|
+
idempotency_key: envelope?.idempotency_key,
|
|
28
|
+
path: envelope?.path,
|
|
29
|
+
primitive_type: envelope?.primitive_type,
|
|
30
|
+
content: envelope?.content,
|
|
31
|
+
patches: envelope?.patches,
|
|
32
|
+
verified_principal_id: principal?.id || null,
|
|
33
|
+
};
|
|
34
|
+
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(clean))).digest('hex')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function failure(envelope, operationId, errors, fieldErrors = []) {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
type: envelope?.type || null,
|
|
41
|
+
operation_id: operationId,
|
|
42
|
+
path: envelope?.path || '',
|
|
43
|
+
committed_at: null,
|
|
44
|
+
dry_run: !!envelope?.dry_run,
|
|
45
|
+
validation: { valid: false, type: null, errors, warnings: [] },
|
|
46
|
+
repair: { field_errors: fieldErrors.length ? fieldErrors : errors.map((issue) => ({ field: '(root)', issue })) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function actionFor(envelope, exists, appendOnly) {
|
|
51
|
+
if (envelope.type === 'operation') return exists ? 'replace' : 'create';
|
|
52
|
+
if (envelope.type === 'patch') return appendOnly && envelope.patches?.__body__ !== undefined ? 'append' : 'patch';
|
|
53
|
+
return envelope.type;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createKernel(options = {}) {
|
|
57
|
+
const {
|
|
58
|
+
vfs,
|
|
59
|
+
eventStore,
|
|
60
|
+
projectionCoordinator,
|
|
61
|
+
leaseStore = null,
|
|
62
|
+
idempotencyStore = new MemoryIdempotencyStore(),
|
|
63
|
+
authorize = createCapabilityAuthorizer(),
|
|
64
|
+
resourceCoordinator = null,
|
|
65
|
+
clock = () => new Date().toISOString(),
|
|
66
|
+
idGenerator = () => crypto.randomUUID(),
|
|
67
|
+
} = options;
|
|
68
|
+
if (!vfs) throw new Error('createKernel requires a VFS adapter.');
|
|
69
|
+
if (!eventStore) throw new Error('createKernel requires an event store.');
|
|
70
|
+
const validator = options.validator || createValidator(options);
|
|
71
|
+
const registrySet = validator.registry;
|
|
72
|
+
|
|
73
|
+
async function execute(rawEnvelope, context = {}) {
|
|
74
|
+
const envelope = { ...rawEnvelope };
|
|
75
|
+
delete envelope.actor;
|
|
76
|
+
const operationId = envelope.operation_id || idGenerator();
|
|
77
|
+
const principal = context.principal;
|
|
78
|
+
|
|
79
|
+
if (!COMMAND_TYPES.has(envelope.type)) {
|
|
80
|
+
return failure(envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
|
|
81
|
+
}
|
|
82
|
+
for (const field of ['workspace_id', 'idempotency_key', 'path']) {
|
|
83
|
+
if (typeof envelope[field] !== 'string' || !envelope[field]) {
|
|
84
|
+
return failure(envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (envelope.type === 'operation' && typeof envelope.content !== 'string') {
|
|
88
|
+
return failure(envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
|
|
89
|
+
}
|
|
90
|
+
if (envelope.type === 'patch' && (!envelope.patches || typeof envelope.patches !== 'object')) {
|
|
91
|
+
return failure(envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const requestHash = canonicalRequestHash(envelope, principal);
|
|
95
|
+
const replay = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
|
|
96
|
+
if (replay) {
|
|
97
|
+
if (replay.request_hash !== requestHash) {
|
|
98
|
+
return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
99
|
+
}
|
|
100
|
+
return { ...replay.response, replay: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let current;
|
|
104
|
+
try { current = await vfs.read(envelope.path); }
|
|
105
|
+
catch (error) { return failure(envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]); }
|
|
106
|
+
|
|
107
|
+
let definition = null;
|
|
108
|
+
let declaredType = envelope.primitive_type || null;
|
|
109
|
+
let nextContent = null;
|
|
110
|
+
let nextData = null;
|
|
111
|
+
let currentData = null;
|
|
112
|
+
|
|
113
|
+
if (current) {
|
|
114
|
+
try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
|
|
115
|
+
catch (error) { return failure(envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
|
|
116
|
+
declaredType ||= currentData.type;
|
|
117
|
+
}
|
|
118
|
+
if (envelope.type === 'operation') {
|
|
119
|
+
if (envelope.path.endsWith('/index.md')) {
|
|
120
|
+
declaredType = 'index';
|
|
121
|
+
definition = { type: 'index', qualified_type: 'ssss:index', primitive_version: 1, capabilities: {} };
|
|
122
|
+
nextContent = envelope.content;
|
|
123
|
+
nextData = {};
|
|
124
|
+
} else {
|
|
125
|
+
const validation = validator.validateDocument(envelope.content, { resolveReference: context.resolveReference });
|
|
126
|
+
if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
|
|
127
|
+
declaredType = validation.declared_type;
|
|
128
|
+
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
129
|
+
nextContent = envelope.content;
|
|
130
|
+
nextData = validation.data;
|
|
131
|
+
}
|
|
132
|
+
if (currentData?.type && currentData.type !== nextData.type) {
|
|
133
|
+
return failure(envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
134
|
+
}
|
|
135
|
+
} else if (envelope.type === 'patch') {
|
|
136
|
+
if (!current) return failure(envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
137
|
+
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
138
|
+
if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
|
|
139
|
+
if (Object.hasOwn(envelope.patches, 'type') && envelope.patches.type !== currentData.type) {
|
|
140
|
+
return failure(envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
141
|
+
}
|
|
142
|
+
nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
|
|
143
|
+
for (const field of definition.immutable_fields || []) {
|
|
144
|
+
if (Object.hasOwn(envelope.patches, field) && JSON.stringify(currentData[field]) !== JSON.stringify(nextData[field])) {
|
|
145
|
+
return failure(envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const parsedCurrent = parseDocument(current.bytes.toString('utf8'));
|
|
149
|
+
const appendOnly = isAppendType(definition);
|
|
150
|
+
let body = parsedCurrent.body;
|
|
151
|
+
if (envelope.patches.__body__ !== undefined) {
|
|
152
|
+
body = appendOnly ? `${body.replace(/\s+$/, '')}\n${envelope.patches.__body__}` : String(envelope.patches.__body__);
|
|
153
|
+
}
|
|
154
|
+
const validation = validator.validateData(nextData, { resolveReference: context.resolveReference });
|
|
155
|
+
if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
|
|
156
|
+
nextContent = serializeDocument(nextData, body);
|
|
157
|
+
} else if (envelope.type === 'delete') {
|
|
158
|
+
if (!current) return failure(envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
159
|
+
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
160
|
+
if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
|
|
161
|
+
if (isAppendType(definition)) return failure(envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
|
|
162
|
+
} else {
|
|
163
|
+
definition = { type: 'event', qualified_type: 'ssss:event', primitive_version: 1, capabilities: { event: ['write:event'] } };
|
|
164
|
+
try { if (typeof envelope.content === 'string') JSON.parse(envelope.content); }
|
|
165
|
+
catch { return failure(envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const appendOnly = isAppendType(definition);
|
|
169
|
+
const action = actionFor(envelope, !!current, appendOnly);
|
|
170
|
+
const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
|
|
171
|
+
if (!auth.allowed) return failure(envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
|
|
172
|
+
|
|
173
|
+
if ((definition?.lease_required || context.requireLease) && leaseStore) {
|
|
174
|
+
let lease;
|
|
175
|
+
try {
|
|
176
|
+
lease = await leaseStore.verify({
|
|
177
|
+
workspace_id: envelope.workspace_id,
|
|
178
|
+
target: envelope.path,
|
|
179
|
+
principal_id: principal.id,
|
|
180
|
+
operation_id: operationId,
|
|
181
|
+
lease_id: envelope.lease_id,
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return failure(envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
|
|
185
|
+
}
|
|
186
|
+
if (!lease.valid) return failure(envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let resourceState = null;
|
|
190
|
+
if (resourceCoordinator?.prepare) {
|
|
191
|
+
try { resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent }); }
|
|
192
|
+
catch (error) { return failure(envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (envelope.dry_run) {
|
|
196
|
+
return {
|
|
197
|
+
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
198
|
+
committed_at: null, dry_run: true,
|
|
199
|
+
validation: { valid: true, type: definition.qualified_type, errors: [], warnings: [] },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let commit = null;
|
|
204
|
+
try {
|
|
205
|
+
if (envelope.type === 'operation') {
|
|
206
|
+
commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
|
|
207
|
+
} else if (envelope.type === 'patch') {
|
|
208
|
+
commit = await vfs.writeAtomic(envelope.path, nextContent, { version: current.version });
|
|
209
|
+
} else if (envelope.type === 'delete') {
|
|
210
|
+
commit = await vfs.remove(envelope.path, { version: current.version });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const event = createCanonicalEvent({
|
|
214
|
+
workspace_id: envelope.workspace_id,
|
|
215
|
+
primitive_id: definition.qualified_type,
|
|
216
|
+
primitive_version: definition.primitive_version || 1,
|
|
217
|
+
action,
|
|
218
|
+
subject: envelope.path,
|
|
219
|
+
principal,
|
|
220
|
+
operation_id: operationId,
|
|
221
|
+
idempotency_key: envelope.idempotency_key,
|
|
222
|
+
request_hash: requestHash,
|
|
223
|
+
before_hash: current?.hash || null,
|
|
224
|
+
after_hash: commit?.hash || null,
|
|
225
|
+
changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
|
|
226
|
+
resource_status: resourceState ? 'prepared' : null,
|
|
227
|
+
payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
|
|
228
|
+
}, { clock });
|
|
229
|
+
await eventStore.append(event);
|
|
230
|
+
|
|
231
|
+
if (resourceCoordinator?.finalize) await resourceCoordinator.finalize({ resourceState, event });
|
|
232
|
+
const projectionResults = projectionCoordinator ? await projectionCoordinator.dispatch(event) : [];
|
|
233
|
+
const warnings = projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`);
|
|
234
|
+
const response = {
|
|
235
|
+
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
236
|
+
committed_at: event.timestamp, dry_run: false,
|
|
237
|
+
event_id: event.event_id,
|
|
238
|
+
validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
|
|
239
|
+
};
|
|
240
|
+
await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response });
|
|
241
|
+
return response;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (resourceCoordinator?.reconcile) {
|
|
244
|
+
try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal }); } catch {}
|
|
245
|
+
}
|
|
246
|
+
// Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
|
|
247
|
+
if (commit && envelope.type !== 'event') {
|
|
248
|
+
try {
|
|
249
|
+
if (!current) await vfs.remove(envelope.path, { version: commit.version });
|
|
250
|
+
else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
|
|
251
|
+
} catch (rollbackError) {
|
|
252
|
+
return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { execute, registry: registrySet, validator };
|
|
260
|
+
}
|