@gregiteen/ssss-cli 0.7.1 → 0.9.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.
- package/CHANGELOG.md +42 -4
- package/README.md +19 -4
- package/VERSION +1 -1
- package/conformance/README.md +11 -7
- package/conformance/fixtures.json +24 -2
- package/conformance/reference-bundle.ucw.json +2 -2
- package/docs/help/bundle.md +6 -2
- package/docs/help/import.md +2 -0
- package/docs/help/overview.md +8 -4
- package/docs/help/semantic.md +38 -0
- package/docs/ssss-spec.md +67 -7
- package/package.json +27 -3
- package/registry/core.json +36 -3
- package/scripts/audit-registry-field-usage.mjs +10 -1
- package/scripts/build-reference-bundle.mjs +4 -1
- package/scripts/cmd-adapter.mjs +29 -0
- package/scripts/cmd-export.mjs +3 -3
- 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 +86 -0
- package/scripts/conformance-09.mjs +199 -0
- package/scripts/conformance.mjs +320 -28
- package/scripts/ssss.mjs +10 -0
- package/skills/ssss/SKILL.md +221 -0
- package/skills/ssss/evals/evals.json +37 -0
- package/skills/ssss/references/contract-reference.md +95 -0
- package/skills/ssss/scripts/validate-schema.mjs +215 -0
- package/skills/ssss/subagents/audit-ssss-contract.md +22 -0
- package/src/authorization.mjs +61 -0
- package/src/bundle.mjs +171 -35
- package/src/engine.mjs +101 -429
- package/src/events.mjs +90 -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 +125 -0
- package/src/primitive.mjs +227 -0
- package/src/projections.mjs +62 -0
- package/src/registry.mjs +408 -17
- package/src/semantic.mjs +210 -0
- 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/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
|
+
}
|
package/src/leases.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Standard lease store semantics. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
function validateLeaseInput(value) {
|
|
7
|
+
for (const field of ['workspace_id', 'target', 'principal_id', 'operation_id']) {
|
|
8
|
+
if (typeof value?.[field] !== 'string' || !value[field]) throw new Error(`Lease ${field} is required.`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MemoryLeaseStore {
|
|
13
|
+
#leases = new Map();
|
|
14
|
+
constructor({ clock = () => Date.now() } = {}) { this.clock = clock; }
|
|
15
|
+
#key(workspaceId, target) { return JSON.stringify([workspaceId, target]); }
|
|
16
|
+
async acquire(input, ttlMs = 30_000) {
|
|
17
|
+
validateLeaseInput(input);
|
|
18
|
+
const key = this.#key(input.workspace_id, input.target);
|
|
19
|
+
const existing = this.#leases.get(key);
|
|
20
|
+
if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
|
|
21
|
+
const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
|
|
22
|
+
this.#leases.set(key, lease);
|
|
23
|
+
return { ...lease };
|
|
24
|
+
}
|
|
25
|
+
async verify(input) {
|
|
26
|
+
const lease = this.#leases.get(this.#key(input.workspace_id, input.target));
|
|
27
|
+
if (!lease) return { valid: false, reason: 'Required lease is missing.' };
|
|
28
|
+
if (lease.expires_at <= this.clock()) return { valid: false, reason: 'Lease has expired.' };
|
|
29
|
+
for (const field of ['lease_id', 'principal_id', 'operation_id']) {
|
|
30
|
+
if (lease[field] !== input[field]) return { valid: false, reason: `Lease ${field} mismatch.` };
|
|
31
|
+
}
|
|
32
|
+
return { valid: true, lease: { ...lease } };
|
|
33
|
+
}
|
|
34
|
+
async renew(input, ttlMs = 30_000) {
|
|
35
|
+
const verified = await this.verify(input);
|
|
36
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
37
|
+
verified.lease.expires_at = this.clock() + ttlMs;
|
|
38
|
+
this.#leases.set(this.#key(input.workspace_id, input.target), verified.lease);
|
|
39
|
+
return { ...verified.lease };
|
|
40
|
+
}
|
|
41
|
+
async release(input) {
|
|
42
|
+
const verified = await this.verify(input);
|
|
43
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
44
|
+
this.#leases.delete(this.#key(input.workspace_id, input.target));
|
|
45
|
+
return { released: true, lease_id: input.lease_id };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FileLeaseStore {
|
|
50
|
+
constructor(root, options = {}) {
|
|
51
|
+
fs.mkdirSync(root, { recursive: true });
|
|
52
|
+
this.root = fs.realpathSync(root);
|
|
53
|
+
this.clock = options.clock || (() => Date.now());
|
|
54
|
+
}
|
|
55
|
+
#file(workspaceId, target) {
|
|
56
|
+
const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
|
|
57
|
+
return path.join(this.root, `${hash}.lease.json`);
|
|
58
|
+
}
|
|
59
|
+
#load(workspaceId, target) {
|
|
60
|
+
const file = this.#file(workspaceId, target);
|
|
61
|
+
if (!fs.existsSync(file)) return null;
|
|
62
|
+
const stat = fs.lstatSync(file);
|
|
63
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('Lease state is unreadable.');
|
|
64
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
65
|
+
catch { throw new Error('Lease state is unreadable.'); }
|
|
66
|
+
}
|
|
67
|
+
#write(lease) {
|
|
68
|
+
const file = this.#file(lease.workspace_id, lease.target);
|
|
69
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
70
|
+
try {
|
|
71
|
+
fs.writeFileSync(temp, `${JSON.stringify(lease)}\n`, { flag: 'wx', mode: 0o600 });
|
|
72
|
+
fs.renameSync(temp, file);
|
|
73
|
+
} finally { fs.rmSync(temp, { force: true }); }
|
|
74
|
+
}
|
|
75
|
+
async acquire(input, ttlMs = 30_000) {
|
|
76
|
+
validateLeaseInput(input);
|
|
77
|
+
const existing = this.#load(input.workspace_id, input.target);
|
|
78
|
+
if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
|
|
79
|
+
const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
|
|
80
|
+
this.#write(lease);
|
|
81
|
+
return { ...lease };
|
|
82
|
+
}
|
|
83
|
+
async verify(input) {
|
|
84
|
+
const lease = this.#load(input.workspace_id, input.target);
|
|
85
|
+
if (!lease) return { valid: false, reason: 'Required lease is missing.' };
|
|
86
|
+
if (lease.expires_at <= this.clock()) return { valid: false, reason: 'Lease has expired.' };
|
|
87
|
+
for (const field of ['lease_id', 'principal_id', 'operation_id']) {
|
|
88
|
+
if (lease[field] !== input[field]) return { valid: false, reason: `Lease ${field} mismatch.` };
|
|
89
|
+
}
|
|
90
|
+
return { valid: true, lease };
|
|
91
|
+
}
|
|
92
|
+
async renew(input, ttlMs = 30_000) {
|
|
93
|
+
const verified = await this.verify(input);
|
|
94
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
95
|
+
verified.lease.expires_at = this.clock() + ttlMs;
|
|
96
|
+
this.#write(verified.lease);
|
|
97
|
+
return verified.lease;
|
|
98
|
+
}
|
|
99
|
+
async release(input) {
|
|
100
|
+
const verified = await this.verify(input);
|
|
101
|
+
if (!verified.valid) throw new Error(verified.reason);
|
|
102
|
+
fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
|
|
103
|
+
return { released: true, lease_id: input.lease_id };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function runLeaseContract(store, { clock } = {}) {
|
|
108
|
+
const input = {
|
|
109
|
+
workspace_id: `ws-${crypto.randomUUID()}`,
|
|
110
|
+
target: `rules/${crypto.randomUUID()}.md`,
|
|
111
|
+
principal_id: 'principal',
|
|
112
|
+
operation_id: 'operation',
|
|
113
|
+
};
|
|
114
|
+
const checks = [];
|
|
115
|
+
const record = (name, passed) => checks.push({ name, passed: !!passed });
|
|
116
|
+
const acquired = await store.acquire(input, 10_000);
|
|
117
|
+
record('acquire returns bound lease', acquired.lease_id && acquired.principal_id === input.principal_id);
|
|
118
|
+
record('matching lease verifies', (await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
|
|
119
|
+
record('mismatched owner fails closed', !(await store.verify({ ...input, principal_id: 'other', lease_id: acquired.lease_id })).valid);
|
|
120
|
+
const renewed = await store.renew({ ...input, lease_id: acquired.lease_id }, 20_000);
|
|
121
|
+
record('renew extends expiry', renewed.expires_at >= acquired.expires_at);
|
|
122
|
+
await store.release({ ...input, lease_id: acquired.lease_id });
|
|
123
|
+
record('released lease is missing', !(await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
|
|
124
|
+
return { passed: checks.every((check) => check.passed), checks };
|
|
125
|
+
}
|