@gregiteen/ssss-cli 0.9.3 → 0.9.5
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 +45 -0
- package/VERSION +1 -1
- package/conformance/fixtures.json +21 -12
- package/conformance/reference-bundle.ucw.json +1 -1
- 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 +156 -3
- 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/frontmatter.mjs +54 -6
- package/src/http.mjs +20 -4
- package/src/kernel.mjs +131 -47
- package/types/kernel.d.ts +7 -0
package/src/kernel.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
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';
|
|
8
|
+
import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './registry.mjs';
|
|
9
|
+
import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
|
|
10
10
|
import { createCanonicalEvent, idempotentEventId } from './events.mjs';
|
|
11
11
|
import { MemoryIdempotencyStore } from './idempotency.mjs';
|
|
12
12
|
|
|
@@ -14,6 +14,17 @@ 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,18 +112,24 @@ 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);
|
|
@@ -97,7 +138,7 @@ export function createKernel(options = {}) {
|
|
|
97
138
|
const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
|
|
98
139
|
if (!stored) return null;
|
|
99
140
|
if (stored.request_hash !== requestHash) {
|
|
100
|
-
return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
141
|
+
return failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
|
|
101
142
|
}
|
|
102
143
|
return { ...stored.response, replay: true };
|
|
103
144
|
};
|
|
@@ -106,7 +147,10 @@ export function createKernel(options = {}) {
|
|
|
106
147
|
|
|
107
148
|
let current;
|
|
108
149
|
try { current = await vfs.read(envelope.path); }
|
|
109
|
-
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
|
+
}
|
|
110
154
|
|
|
111
155
|
let definition = null;
|
|
112
156
|
let declaredType = envelope.primitive_type || null;
|
|
@@ -116,7 +160,7 @@ export function createKernel(options = {}) {
|
|
|
116
160
|
|
|
117
161
|
if (current) {
|
|
118
162
|
try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
|
|
119
|
-
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}`]); }
|
|
120
164
|
declaredType ||= currentData.type;
|
|
121
165
|
}
|
|
122
166
|
if (envelope.type === 'operation') {
|
|
@@ -126,27 +170,35 @@ export function createKernel(options = {}) {
|
|
|
126
170
|
nextContent = envelope.content;
|
|
127
171
|
nextData = {};
|
|
128
172
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
179
|
declaredType = validation.declared_type;
|
|
132
180
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
133
181
|
nextContent = envelope.content;
|
|
134
182
|
nextData = validation.data;
|
|
135
183
|
}
|
|
136
184
|
if (currentData?.type && currentData.type !== nextData.type) {
|
|
137
|
-
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__.' }]);
|
|
138
190
|
}
|
|
139
191
|
} else if (envelope.type === 'patch') {
|
|
140
|
-
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.' }]);
|
|
141
193
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
142
|
-
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.' }]);
|
|
143
195
|
if (Object.hasOwn(envelope.patches, 'type') && envelope.patches.type !== currentData.type) {
|
|
144
|
-
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.' }]);
|
|
145
197
|
}
|
|
146
198
|
nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
|
|
147
199
|
for (const field of definition.immutable_fields || []) {
|
|
148
200
|
if (Object.hasOwn(envelope.patches, field) && JSON.stringify(currentData[field]) !== JSON.stringify(nextData[field])) {
|
|
149
|
-
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.' }]);
|
|
150
202
|
}
|
|
151
203
|
}
|
|
152
204
|
const parsedCurrent = parseDocument(current.bytes.toString('utf8'));
|
|
@@ -155,24 +207,29 @@ export function createKernel(options = {}) {
|
|
|
155
207
|
if (envelope.patches.__body__ !== undefined) {
|
|
156
208
|
body = appendOnly ? `${body.replace(/\s+$/, '')}\n${envelope.patches.__body__}` : String(envelope.patches.__body__);
|
|
157
209
|
}
|
|
158
|
-
const
|
|
159
|
-
|
|
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);
|
|
160
213
|
nextContent = serializeDocument(nextData, body);
|
|
161
214
|
} else if (envelope.type === 'delete') {
|
|
162
|
-
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.' }]);
|
|
163
216
|
definition = resolvePrimitiveDefinition(registrySet, declaredType);
|
|
164
|
-
if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
|
|
165
|
-
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.`]);
|
|
166
219
|
} else {
|
|
167
220
|
definition = { type: 'event', qualified_type: 'ssss:event', primitive_version: 1, capabilities: { event: ['write:event'] } };
|
|
168
221
|
try { if (typeof envelope.content === 'string') JSON.parse(envelope.content); }
|
|
169
|
-
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.' }]); }
|
|
170
223
|
}
|
|
171
224
|
|
|
172
225
|
const appendOnly = isAppendType(definition);
|
|
173
226
|
const action = actionFor(envelope, !!current, appendOnly);
|
|
174
227
|
const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
|
|
175
|
-
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
|
+
}
|
|
176
233
|
|
|
177
234
|
if ((definition?.lease_required || context.requireLease) && leaseStore) {
|
|
178
235
|
let lease;
|
|
@@ -185,18 +242,30 @@ export function createKernel(options = {}) {
|
|
|
185
242
|
lease_id: envelope.lease_id,
|
|
186
243
|
});
|
|
187
244
|
} catch (error) {
|
|
188
|
-
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.' }]);
|
|
189
246
|
}
|
|
190
|
-
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 }]);
|
|
191
248
|
}
|
|
192
249
|
|
|
193
250
|
let resourceState = null;
|
|
251
|
+
let prepared = false;
|
|
194
252
|
if (resourceCoordinator?.prepare) {
|
|
195
|
-
try {
|
|
196
|
-
|
|
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}`]); }
|
|
197
258
|
}
|
|
198
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
|
+
|
|
199
267
|
if (envelope.dry_run) {
|
|
268
|
+
if (prepared) await reconcile('dry_run', null);
|
|
200
269
|
return {
|
|
201
270
|
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
202
271
|
committed_at: null, dry_run: true,
|
|
@@ -205,6 +274,7 @@ export function createKernel(options = {}) {
|
|
|
205
274
|
}
|
|
206
275
|
|
|
207
276
|
let commit = null;
|
|
277
|
+
let event;
|
|
208
278
|
try {
|
|
209
279
|
if (envelope.type === 'operation') {
|
|
210
280
|
commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
|
|
@@ -214,7 +284,7 @@ export function createKernel(options = {}) {
|
|
|
214
284
|
commit = await vfs.remove(envelope.path, { version: current.version });
|
|
215
285
|
}
|
|
216
286
|
|
|
217
|
-
|
|
287
|
+
event = createCanonicalEvent({
|
|
218
288
|
// `event` envelopes have no VFS compare-and-swap, so a key-derived id
|
|
219
289
|
// lets the event store reject a concurrent request with the same key.
|
|
220
290
|
event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
|
|
@@ -230,33 +300,19 @@ export function createKernel(options = {}) {
|
|
|
230
300
|
before_hash: current?.hash || null,
|
|
231
301
|
after_hash: commit?.hash || null,
|
|
232
302
|
changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
|
|
233
|
-
resource_status:
|
|
303
|
+
resource_status: prepared ? 'prepared' : null,
|
|
234
304
|
payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
|
|
235
305
|
}, { clock });
|
|
236
306
|
await eventStore.append(event);
|
|
237
|
-
|
|
238
|
-
if (resourceCoordinator?.finalize) await resourceCoordinator.finalize({ resourceState, event });
|
|
239
|
-
const projectionResults = projectionCoordinator ? await projectionCoordinator.dispatch(event) : [];
|
|
240
|
-
const warnings = projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`);
|
|
241
|
-
const response = {
|
|
242
|
-
success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
|
|
243
|
-
committed_at: event.timestamp, dry_run: false,
|
|
244
|
-
event_id: event.event_id,
|
|
245
|
-
validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
|
|
246
|
-
};
|
|
247
|
-
await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response });
|
|
248
|
-
return response;
|
|
249
307
|
} catch (error) {
|
|
250
|
-
if (
|
|
251
|
-
try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal }); } catch {}
|
|
252
|
-
}
|
|
308
|
+
if (prepared) await reconcile('commit', error);
|
|
253
309
|
// Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
|
|
254
310
|
if (commit && envelope.type !== 'event') {
|
|
255
311
|
try {
|
|
256
312
|
if (!current) await vfs.remove(envelope.path, { version: commit.version });
|
|
257
313
|
else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
|
|
258
314
|
} catch (rollbackError) {
|
|
259
|
-
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}`]);
|
|
260
316
|
}
|
|
261
317
|
}
|
|
262
318
|
// A concurrent request with the same key may have won the commit (VFS
|
|
@@ -265,8 +321,36 @@ export function createKernel(options = {}) {
|
|
|
265
321
|
const winner = await settled();
|
|
266
322
|
if (winner) return winner;
|
|
267
323
|
} catch {}
|
|
268
|
-
|
|
324
|
+
const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
|
|
325
|
+
return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
|
|
269
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}`); }
|
|
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;
|
|
270
354
|
}
|
|
271
355
|
|
|
272
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>;
|