@gregiteen/ssss-cli 0.9.2 → 0.9.4
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 +39 -0
- package/VERSION +1 -1
- package/conformance/fixtures.json +21 -12
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/help/concurrency.md +17 -4
- package/docs/ssss-spec.md +536 -184
- package/package.json +1 -1
- package/scripts/audit-spec-examples.mjs +79 -0
- package/scripts/conformance-09.mjs +184 -4
- package/scripts/conformance.mjs +15 -1
- package/scripts/generate-sbom.mjs +13 -14
- package/src/authorization.mjs +11 -1
- package/src/engine.mjs +23 -4
- package/src/events.mjs +16 -0
- package/src/frontmatter.mjs +54 -6
- package/src/http.mjs +20 -4
- package/src/kernel.mjs +150 -53
- package/types/kernel.d.ts +7 -0
package/src/http.mjs
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
/** Reference transport adapters. HTTP is a façade over kernel.execute, never a writer. */
|
|
2
2
|
|
|
3
|
+
/** Canonical HTTP status for each symbolic failure code (spec §6.5). */
|
|
4
|
+
export const ERROR_STATUS = Object.freeze({
|
|
5
|
+
invalid_request: 400,
|
|
6
|
+
unauthorized: 401,
|
|
7
|
+
forbidden: 403,
|
|
8
|
+
not_found: 404,
|
|
9
|
+
lease_conflict: 409,
|
|
10
|
+
version_conflict: 409,
|
|
11
|
+
idempotency_conflict: 409,
|
|
12
|
+
validation_failed: 422,
|
|
13
|
+
internal_error: 500,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Map a kernel response to its HTTP status: 201 commit, 200 replay/dry run, else by error code. */
|
|
17
|
+
export function statusForResponse(response) {
|
|
18
|
+
if (response?.success) return response.replay || response.dry_run ? 200 : 201;
|
|
19
|
+
return ERROR_STATUS[response?.error?.code] || 500;
|
|
20
|
+
}
|
|
21
|
+
|
|
3
22
|
export function createCommandHandler({ kernel, authenticate, parseBody = async (request) => request.json() }) {
|
|
4
23
|
if (!kernel?.execute || typeof authenticate !== 'function') throw new Error('kernel and authenticate(request) are required.');
|
|
5
24
|
return async function handle(request) {
|
|
@@ -7,10 +26,7 @@ export function createCommandHandler({ kernel, authenticate, parseBody = async (
|
|
|
7
26
|
const principal = await authenticate(request);
|
|
8
27
|
const envelope = await parseBody(request);
|
|
9
28
|
const response = await kernel.execute(envelope, { principal, request });
|
|
10
|
-
|
|
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 };
|
|
29
|
+
return { status: statusForResponse(response), headers: { 'content-type': 'application/json' }, body: response };
|
|
14
30
|
} catch (error) {
|
|
15
31
|
return { status: 400, headers: { 'content-type': 'application/json' }, body: { success: false, error: { code: 'invalid_request', message: error.message } } };
|
|
16
32
|
}
|
package/src/kernel.mjs
CHANGED
|
@@ -5,15 +5,26 @@
|
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import { parseDocument, serializeDocument } from './frontmatter.mjs';
|
|
7
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';
|
|
8
|
+
import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './registry.mjs';
|
|
9
|
+
import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
|
|
10
|
+
import { createCanonicalEvent, idempotentEventId } from './events.mjs';
|
|
11
11
|
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
12
12
|
|
|
13
13
|
export { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
14
14
|
|
|
15
15
|
const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Symbolic failure codes (spec §6.5). Every failed response carries exactly one
|
|
19
|
+
* in `error.code`; transports map codes to status without parsing messages.
|
|
20
|
+
*/
|
|
21
|
+
export const ERROR_CODES = Object.freeze([
|
|
22
|
+
'invalid_request', 'unauthorized', 'forbidden', 'not_found', 'lease_conflict',
|
|
23
|
+
'version_conflict', 'idempotency_conflict', 'validation_failed', 'internal_error',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function isVfsError(error, name, code) { return error?.name === name || error?.code === code; }
|
|
27
|
+
|
|
17
28
|
function stable(value) {
|
|
18
29
|
if (Array.isArray(value)) return value.map(stable);
|
|
19
30
|
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
|
@@ -34,9 +45,10 @@ export function canonicalRequestHash(envelope, principal) {
|
|
|
34
45
|
return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(clean))).digest('hex')}`;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
function failure(envelope, operationId, errors, fieldErrors = []) {
|
|
48
|
+
function failure(code, envelope, operationId, errors, fieldErrors = []) {
|
|
38
49
|
return {
|
|
39
50
|
success: false,
|
|
51
|
+
error: { code, message: errors[0] || code },
|
|
40
52
|
type: envelope?.type || null,
|
|
41
53
|
operation_id: operationId,
|
|
42
54
|
path: envelope?.path || '',
|
|
@@ -70,6 +82,29 @@ export function createKernel(options = {}) {
|
|
|
70
82
|
const validator = options.validator || createValidator(options);
|
|
71
83
|
const registrySet = validator.registry;
|
|
72
84
|
|
|
85
|
+
// Registry `references` are enforced on every write (spec §9). A host may
|
|
86
|
+
// supply its own resolver; otherwise targets are read through the VFS.
|
|
87
|
+
async function referenceResolver(definition, data, context) {
|
|
88
|
+
if (context.resolveReference) return context.resolveReference;
|
|
89
|
+
const fields = Object.keys(definition?.references || {});
|
|
90
|
+
if (!fields.length || !data) return undefined;
|
|
91
|
+
const found = new Map();
|
|
92
|
+
for (const field of fields) {
|
|
93
|
+
const target = data[field];
|
|
94
|
+
if (!isSafeDocumentPath(target) || found.has(target)) continue;
|
|
95
|
+
let entry = null;
|
|
96
|
+
try {
|
|
97
|
+
const file = await vfs.read(target);
|
|
98
|
+
if (file) {
|
|
99
|
+
const content = file.bytes.toString('utf8');
|
|
100
|
+
entry = { content, data: parseDocument(content).data };
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
found.set(target, entry);
|
|
104
|
+
}
|
|
105
|
+
return (target) => found.get(target) || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
73
108
|
async function execute(rawEnvelope, context = {}) {
|
|
74
109
|
const envelope = { ...rawEnvelope };
|
|
75
110
|
delete envelope.actor;
|
|
@@ -77,32 +112,45 @@ export function createKernel(options = {}) {
|
|
|
77
112
|
const principal = context.principal;
|
|
78
113
|
|
|
79
114
|
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.' }]);
|
|
115
|
+
return failure('invalid_request', envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
|
|
81
116
|
}
|
|
82
117
|
for (const field of ['workspace_id', 'idempotency_key', 'path']) {
|
|
83
118
|
if (typeof envelope[field] !== 'string' || !envelope[field]) {
|
|
84
|
-
return failure(envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
|
|
119
|
+
return failure('invalid_request', envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
|
|
85
120
|
}
|
|
86
121
|
}
|
|
87
122
|
if (envelope.type === 'operation' && typeof envelope.content !== 'string') {
|
|
88
|
-
return failure(envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
|
|
123
|
+
return failure('invalid_request', envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
|
|
89
124
|
}
|
|
90
125
|
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.' }]);
|
|
126
|
+
return failure('invalid_request', envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Authenticate before touching storage, so an anonymous caller learns
|
|
130
|
+
// nothing about the vault. The id and kind are what every event records.
|
|
131
|
+
if (!principal || typeof principal.id !== 'string' || !principal.id || typeof principal.kind !== 'string' || !principal.kind) {
|
|
132
|
+
return failure('unauthorized', envelope, operationId, ['Access denied: Verified principal is required.'], [{ field: 'principal', issue: 'Verified principal is required.' }]);
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
const requestHash = canonicalRequestHash(envelope, principal);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
136
|
+
// A stored result for this key: replay it, or refuse a different request.
|
|
137
|
+
const settled = async () => {
|
|
138
|
+
const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
|
|
139
|
+
if (!stored) return null;
|
|
140
|
+
if (stored.request_hash !== requestHash) {
|
|
141
|
+
return failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
99
142
|
}
|
|
100
|
-
return { ...
|
|
101
|
-
}
|
|
143
|
+
return { ...stored.response, replay: true };
|
|
144
|
+
};
|
|
145
|
+
const replay = await settled();
|
|
146
|
+
if (replay) return replay;
|
|
102
147
|
|
|
103
148
|
let current;
|
|
104
149
|
try { current = await vfs.read(envelope.path); }
|
|
105
|
-
catch (error) {
|
|
150
|
+
catch (error) {
|
|
151
|
+
const code = isVfsError(error, 'VfsPathError', 'SSSS_VFS_PATH') ? 'invalid_request' : 'internal_error';
|
|
152
|
+
return failure(code, envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]);
|
|
153
|
+
}
|
|
106
154
|
|
|
107
155
|
let definition = null;
|
|
108
156
|
let declaredType = envelope.primitive_type || null;
|
|
@@ -112,7 +160,7 @@ export function createKernel(options = {}) {
|
|
|
112
160
|
|
|
113
161
|
if (current) {
|
|
114
162
|
try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
|
|
115
|
-
catch (error) { return failure(envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
|
|
163
|
+
catch (error) { return failure('internal_error', envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
|
|
116
164
|
declaredType ||= currentData.type;
|
|
117
165
|
}
|
|
118
166
|
if (envelope.type === 'operation') {
|
|
@@ -122,27 +170,35 @@ export function createKernel(options = {}) {
|
|
|
122
170
|
nextContent = envelope.content;
|
|
123
171
|
nextData = {};
|
|
124
172
|
} else {
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
let draft = null;
|
|
174
|
+
try { draft = parseDocument(envelope.content).data; } catch {}
|
|
175
|
+
const draftDefinition = draft && resolvePrimitiveDefinition(registrySet, draft.type);
|
|
176
|
+
const resolveReference = await referenceResolver(draftDefinition, draft, context);
|
|
177
|
+
const validation = validator.validateDocument(envelope.content, { resolveReference });
|
|
178
|
+
if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
|
|
127
179
|
declaredType = validation.declared_type;
|
|
128
180
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
129
181
|
nextContent = envelope.content;
|
|
130
182
|
nextData = validation.data;
|
|
131
183
|
}
|
|
132
184
|
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.' }]);
|
|
185
|
+
return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
186
|
+
}
|
|
187
|
+
// A full replace would rewrite recorded history (spec §5.3).
|
|
188
|
+
if (current && isAppendType(definition)) {
|
|
189
|
+
return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
|
|
134
190
|
}
|
|
135
191
|
} else if (envelope.type === 'patch') {
|
|
136
|
-
if (!current) return failure(envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
192
|
+
if (!current) return failure('not_found', envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
137
193
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
138
|
-
if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
|
|
194
|
+
if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
|
|
139
195
|
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.' }]);
|
|
196
|
+
return failure('validation_failed', envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
|
|
141
197
|
}
|
|
142
198
|
nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
|
|
143
199
|
for (const field of definition.immutable_fields || []) {
|
|
144
200
|
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.' }]);
|
|
201
|
+
return failure('validation_failed', envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
|
|
146
202
|
}
|
|
147
203
|
}
|
|
148
204
|
const parsedCurrent = parseDocument(current.bytes.toString('utf8'));
|
|
@@ -151,24 +207,29 @@ export function createKernel(options = {}) {
|
|
|
151
207
|
if (envelope.patches.__body__ !== undefined) {
|
|
152
208
|
body = appendOnly ? `${body.replace(/\s+$/, '')}\n${envelope.patches.__body__}` : String(envelope.patches.__body__);
|
|
153
209
|
}
|
|
154
|
-
const
|
|
155
|
-
|
|
210
|
+
const resolveReference = await referenceResolver(definition, nextData, context);
|
|
211
|
+
const validation = validator.validateData(nextData, { resolveReference });
|
|
212
|
+
if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
|
|
156
213
|
nextContent = serializeDocument(nextData, body);
|
|
157
214
|
} else if (envelope.type === 'delete') {
|
|
158
|
-
if (!current) return failure(envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
215
|
+
if (!current) return failure('not_found', envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
|
|
159
216
|
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.`]);
|
|
217
|
+
if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
|
|
218
|
+
if (isAppendType(definition)) return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
|
|
162
219
|
} else {
|
|
163
220
|
definition = { type: 'event', qualified_type: 'ssss:event', primitive_version: 1, capabilities: { event: ['write:event'] } };
|
|
164
221
|
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.' }]); }
|
|
222
|
+
catch { return failure('invalid_request', envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
|
|
166
223
|
}
|
|
167
224
|
|
|
168
225
|
const appendOnly = isAppendType(definition);
|
|
169
226
|
const action = actionFor(envelope, !!current, appendOnly);
|
|
170
227
|
const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
|
|
171
|
-
if (!auth.allowed)
|
|
228
|
+
if (!auth.allowed) {
|
|
229
|
+
// An absent or malformed principal is an authentication failure, not a permission one.
|
|
230
|
+
const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
|
|
231
|
+
return failure(code, envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
|
|
232
|
+
}
|
|
172
233
|
|
|
173
234
|
if ((definition?.lease_required || context.requireLease) && leaseStore) {
|
|
174
235
|
let lease;
|
|
@@ -181,18 +242,30 @@ export function createKernel(options = {}) {
|
|
|
181
242
|
lease_id: envelope.lease_id,
|
|
182
243
|
});
|
|
183
244
|
} catch (error) {
|
|
184
|
-
return failure(envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
|
|
245
|
+
return failure('internal_error', envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
|
|
185
246
|
}
|
|
186
|
-
if (!lease.valid) return failure(envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
|
|
247
|
+
if (!lease.valid) return failure('lease_conflict', envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
let resourceState = null;
|
|
251
|
+
let prepared = false;
|
|
190
252
|
if (resourceCoordinator?.prepare) {
|
|
191
|
-
try {
|
|
192
|
-
|
|
253
|
+
try {
|
|
254
|
+
resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent });
|
|
255
|
+
prepared = true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) { return failure('internal_error', envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
|
|
193
258
|
}
|
|
194
259
|
|
|
260
|
+
// Every prepare ends in exactly one finalize or reconcile; a dry run never commits.
|
|
261
|
+
const reconcile = async (phase, error) => {
|
|
262
|
+
if (!resourceCoordinator?.reconcile) return null;
|
|
263
|
+
try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal, phase }); return null; }
|
|
264
|
+
catch (reconcileError) { return reconcileError; }
|
|
265
|
+
};
|
|
266
|
+
|
|
195
267
|
if (envelope.dry_run) {
|
|
268
|
+
if (prepared) await reconcile('dry_run', null);
|
|
196
269
|
return {
|
|
197
270
|
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
198
271
|
committed_at: null, dry_run: true,
|
|
@@ -201,6 +274,7 @@ export function createKernel(options = {}) {
|
|
|
201
274
|
}
|
|
202
275
|
|
|
203
276
|
let commit = null;
|
|
277
|
+
let event;
|
|
204
278
|
try {
|
|
205
279
|
if (envelope.type === 'operation') {
|
|
206
280
|
commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
|
|
@@ -210,7 +284,10 @@ export function createKernel(options = {}) {
|
|
|
210
284
|
commit = await vfs.remove(envelope.path, { version: current.version });
|
|
211
285
|
}
|
|
212
286
|
|
|
213
|
-
|
|
287
|
+
event = createCanonicalEvent({
|
|
288
|
+
// `event` envelopes have no VFS compare-and-swap, so a key-derived id
|
|
289
|
+
// lets the event store reject a concurrent request with the same key.
|
|
290
|
+
event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
|
|
214
291
|
workspace_id: envelope.workspace_id,
|
|
215
292
|
primitive_id: definition.qualified_type,
|
|
216
293
|
primitive_version: definition.primitive_version || 1,
|
|
@@ -223,37 +300,57 @@ export function createKernel(options = {}) {
|
|
|
223
300
|
before_hash: current?.hash || null,
|
|
224
301
|
after_hash: commit?.hash || null,
|
|
225
302
|
changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
|
|
226
|
-
resource_status:
|
|
303
|
+
resource_status: prepared ? 'prepared' : null,
|
|
227
304
|
payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
|
|
228
305
|
}, { clock });
|
|
229
306
|
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
307
|
} catch (error) {
|
|
243
|
-
if (
|
|
244
|
-
try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal }); } catch {}
|
|
245
|
-
}
|
|
308
|
+
if (prepared) await reconcile('commit', error);
|
|
246
309
|
// Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
|
|
247
310
|
if (commit && envelope.type !== 'event') {
|
|
248
311
|
try {
|
|
249
312
|
if (!current) await vfs.remove(envelope.path, { version: commit.version });
|
|
250
313
|
else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
|
|
251
314
|
} catch (rollbackError) {
|
|
252
|
-
return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
|
|
315
|
+
return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
|
|
253
316
|
}
|
|
254
317
|
}
|
|
255
|
-
|
|
318
|
+
// A concurrent request with the same key may have won the commit (VFS
|
|
319
|
+
// compare-and-swap or duplicate event_id); answer with its result.
|
|
320
|
+
try {
|
|
321
|
+
const winner = await settled();
|
|
322
|
+
if (winner) return winner;
|
|
323
|
+
} catch {}
|
|
324
|
+
const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
|
|
325
|
+
return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The appended event is the commit point (spec §6.3): nothing after it may
|
|
329
|
+
// roll the mutation back. Post-commit failures become warnings.
|
|
330
|
+
const warnings = [];
|
|
331
|
+
if (resourceCoordinator?.finalize) {
|
|
332
|
+
try { await resourceCoordinator.finalize({ resourceState, event }); }
|
|
333
|
+
catch (error) {
|
|
334
|
+
warnings.push(`Resource finalize failed: ${error.message}`);
|
|
335
|
+
const reconcileError = await reconcile('finalize', error);
|
|
336
|
+
if (reconcileError) warnings.push(`Resource reconcile failed: ${reconcileError.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
let projectionResults = [];
|
|
340
|
+
if (projectionCoordinator) {
|
|
341
|
+
try { projectionResults = await projectionCoordinator.dispatch(event); }
|
|
342
|
+
catch (error) { warnings.push(`Projection dispatch failed: ${error.message}`); }
|
|
256
343
|
}
|
|
344
|
+
warnings.push(...projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`));
|
|
345
|
+
const response = {
|
|
346
|
+
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
347
|
+
committed_at: event.timestamp, dry_run: false,
|
|
348
|
+
event_id: event.event_id,
|
|
349
|
+
validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
|
|
350
|
+
};
|
|
351
|
+
try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); }
|
|
352
|
+
catch (error) { warnings.push(`Idempotency record failed: ${error.message}`); }
|
|
353
|
+
return response;
|
|
257
354
|
}
|
|
258
355
|
|
|
259
356
|
return { execute, registry: registrySet, validator };
|
package/types/kernel.d.ts
CHANGED
|
@@ -21,8 +21,14 @@ export interface SsssCommand {
|
|
|
21
21
|
dry_run?: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export type SsssErrorCode =
|
|
25
|
+
| 'invalid_request' | 'unauthorized' | 'forbidden' | 'not_found' | 'lease_conflict'
|
|
26
|
+
| 'version_conflict' | 'idempotency_conflict' | 'validation_failed' | 'internal_error';
|
|
27
|
+
|
|
24
28
|
export interface SsssResponse {
|
|
25
29
|
success: boolean;
|
|
30
|
+
/** Present exactly when success is false (spec §6.5). */
|
|
31
|
+
error?: { code: SsssErrorCode; message: string };
|
|
26
32
|
type: SsssCommandType | null;
|
|
27
33
|
operation_id: string;
|
|
28
34
|
path: string;
|
|
@@ -40,6 +46,7 @@ export interface SsssKernel {
|
|
|
40
46
|
validator: unknown;
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
export const ERROR_CODES: readonly SsssErrorCode[];
|
|
43
50
|
export function canonicalRequestHash(command: SsssCommand, principal: VerifiedPrincipal): string;
|
|
44
51
|
export class MemoryIdempotencyStore {
|
|
45
52
|
get(workspaceId: string, idempotencyKey: string): Promise<unknown>;
|