@gregiteen/ssss-cli 0.12.1 → 0.13.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/src/kernel.mjs CHANGED
@@ -9,8 +9,10 @@ import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './
9
9
  import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
10
10
  import { createCanonicalEvent, idempotentEventId } from './events.mjs';
11
11
  import { MemoryIdempotencyStore } from './idempotency.mjs';
12
+ import { MemoryIntentJournal } from './intents.mjs';
12
13
 
13
14
  export { MemoryIdempotencyStore } from './idempotency.mjs';
15
+ export { FileIntentJournal, MemoryIntentJournal } from './intents.mjs';
14
16
 
15
17
  const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
16
18
 
@@ -25,6 +27,8 @@ export const ERROR_CODES = Object.freeze([
25
27
 
26
28
  function isVfsError(error, name, code) { return error?.name === name || error?.code === code; }
27
29
 
30
+ function digestBytes(bytes) { return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; }
31
+
28
32
  function stable(value) {
29
33
  if (Array.isArray(value)) return value.map(stable);
30
34
  if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
@@ -59,6 +63,20 @@ function failure(code, envelope, operationId, errors, fieldErrors = []) {
59
63
  };
60
64
  }
61
65
 
66
+ /**
67
+ * The success response of a committed envelope, rebuilt from its event. The
68
+ * request hash covers `type` and `path`, so those come from the envelope.
69
+ * Post-commit warnings are not in the log and are therefore absent.
70
+ */
71
+ function responseFromEvent(envelope, event) {
72
+ return {
73
+ success: true, type: envelope.type, operation_id: event.operation_id, path: envelope.path,
74
+ committed_at: event.timestamp, dry_run: false,
75
+ event_id: event.event_id,
76
+ validation: { valid: true, type: event.primitive_id, errors: [], warnings: [] },
77
+ };
78
+ }
79
+
62
80
  function actionFor(envelope, exists, appendOnly) {
63
81
  if (envelope.type === 'operation') return exists ? 'replace' : 'create';
64
82
  if (envelope.type === 'patch') return appendOnly && envelope.patches?.__body__ !== undefined ? 'append' : 'patch';
@@ -81,6 +99,60 @@ export function createKernel(options = {}) {
81
99
  if (!eventStore) throw new Error('createKernel requires an event store.');
82
100
  const validator = options.validator || createValidator(options);
83
101
  const registrySet = validator.registry;
102
+ // A VFS whose state outlives the process supplies its own journal, so hosts
103
+ // get crash recovery without wiring one (spec §6.7).
104
+ const intentJournal = options.intentJournal || vfs.intentJournal?.() || new MemoryIntentJournal();
105
+
106
+ async function eventCommitted(intent) {
107
+ if (typeof eventStore.findByIdempotencyKey === 'function') {
108
+ const first = await eventStore.findByIdempotencyKey(intent.workspace_id, intent.idempotency_key);
109
+ if (!first) return false;
110
+ if (first.event_id === intent.event_id) return true;
111
+ }
112
+ // A store without key lookup, or a key first used by another request:
113
+ // scan. This runs only after a crash.
114
+ for await (const { event } of eventStore.replay({ workspaceId: intent.workspace_id })) {
115
+ if (event.event_id === intent.event_id) return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ /**
121
+ * Decide a commit intent whose writer is gone (spec §6.3). The event is the
122
+ * commit point: if it is in the log the commit stands; if not, the vault is
123
+ * returned to the prior content. Digests are the kernel's own, over raw
124
+ * bytes, so the decision does not depend on a VFS's version scheme.
125
+ */
126
+ async function resolveIntent(intent) {
127
+ try {
128
+ if (await eventCommitted(intent)) return { outcome: 'committed' };
129
+ const now = await vfs.read(intent.path);
130
+ const nowDigest = now ? digestBytes(now.bytes) : null;
131
+ const beforeDigest = intent.before?.exists ? intent.before.digest : null;
132
+ if (nowDigest === beforeDigest) return { outcome: 'discarded' };
133
+ if (nowDigest === (intent.after_digest ?? null)) {
134
+ if (intent.before?.exists) {
135
+ await vfs.writeAtomic(intent.path, Buffer.from(intent.before.bytes, 'base64'), now ? { version: now.version } : { ifAbsent: true });
136
+ } else {
137
+ await vfs.remove(intent.path, { version: now.version });
138
+ }
139
+ return { outcome: 'rolled_back' };
140
+ }
141
+ return { outcome: 'conflict', reason: 'The document changed outside the kernel after an uncommitted write.' };
142
+ } catch (error) {
143
+ return { outcome: 'failed', reason: error.message };
144
+ }
145
+ }
146
+
147
+ /** Resolve every commit intent whose writer is gone; report the rest. */
148
+ async function recover() {
149
+ return intentJournal.recover(resolveIntent);
150
+ }
151
+
152
+ // Once per kernel, before its first operation, so state a crashed writer
153
+ // left behind is rolled back even on paths nobody writes again.
154
+ let recovery = null;
155
+ const recoverOnce = () => (recovery ||= recover().catch((error) => { recovery = null; throw error; }));
84
156
 
85
157
  // Registry `references` are enforced on every write (spec §9). A host may
86
158
  // supply its own resolver; otherwise targets are read through the VFS.
@@ -132,15 +204,28 @@ export function createKernel(options = {}) {
132
204
  return failure('unauthorized', envelope, operationId, ['Access denied: Verified principal is required.'], [{ field: 'principal', issue: 'Verified principal is required.' }]);
133
205
  }
134
206
 
207
+ try { await recoverOnce(); }
208
+ catch (error) { return failure('internal_error', envelope, operationId, [`Commit recovery failed: ${error.message}`]); }
209
+
135
210
  const requestHash = canonicalRequestHash(envelope, principal);
136
- // A stored result for this key: replay it, or refuse a different request.
211
+ const keyConflict = () => failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
212
+ // A recorded result for this key: replay it, or refuse a different request.
213
+ // The idempotency store is a cache of full responses; the event log is the
214
+ // durable record of every committed key (spec §6.4), so a cache miss after
215
+ // retention, a lost record, or a new process still finds the commit.
137
216
  const settled = async () => {
138
217
  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.' }]);
142
- }
143
- return { ...stored.response, replay: true };
218
+ if (stored) return stored.request_hash === requestHash ? { ...stored.response, replay: true } : keyConflict();
219
+ if (typeof eventStore.findByIdempotencyKey !== 'function') return null;
220
+ let committed;
221
+ try { committed = await eventStore.findByIdempotencyKey(envelope.workspace_id, envelope.idempotency_key); }
222
+ catch (error) { return failure('internal_error', envelope, operationId, [`Event log is unreadable: ${error.message}`]); }
223
+ if (!committed) return null;
224
+ // An event without a request hash cannot prove it was this request.
225
+ if (committed.request_hash !== requestHash) return keyConflict();
226
+ const response = responseFromEvent(envelope, committed);
227
+ try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); } catch {}
228
+ return { ...response, replay: true };
144
229
  };
145
230
  const replay = await settled();
146
231
  if (replay) return replay;
@@ -295,61 +380,97 @@ export function createKernel(options = {}) {
295
380
  };
296
381
  }
297
382
 
298
- let commit = null;
299
- let event;
300
- try {
301
- if (envelope.type === 'operation') {
302
- commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
303
- } else if (envelope.type === 'patch') {
304
- commit = await vfs.writeAtomic(envelope.path, nextContent, { version: current.version });
305
- } else if (envelope.type === 'delete') {
306
- commit = await vfs.remove(envelope.path, { version: current.version });
383
+ // `event` envelopes have no VFS compare-and-swap, so a key-derived id lets
384
+ // the event store reject a concurrent request with the same key. Other ids
385
+ // are assigned now so the commit intent can name the event it expects.
386
+ const eventId = envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : crypto.randomUUID();
387
+ let committedEvent = null;
388
+
389
+ // Stages 9 and 10. Returns { event } on commit, or { response, keepIntent }.
390
+ const commitAndAppend = async () => {
391
+ let commit = null;
392
+ let event;
393
+ try {
394
+ if (envelope.type === 'operation') {
395
+ commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
396
+ } else if (envelope.type === 'patch') {
397
+ commit = await vfs.writeAtomic(envelope.path, nextContent, { version: current.version });
398
+ } else if (envelope.type === 'delete') {
399
+ commit = await vfs.remove(envelope.path, { version: current.version });
400
+ }
401
+
402
+ event = createCanonicalEvent({
403
+ event_id: eventId,
404
+ workspace_id: envelope.workspace_id,
405
+ primitive_id: definition.qualified_type,
406
+ primitive_version: definition.primitive_version || 1,
407
+ action,
408
+ subject: envelope.path,
409
+ principal,
410
+ operation_id: operationId,
411
+ idempotency_key: envelope.idempotency_key,
412
+ request_hash: requestHash,
413
+ before_hash: current?.hash || null,
414
+ after_hash: commit?.hash || null,
415
+ changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
416
+ resource_status: prepared ? 'prepared' : null,
417
+ payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
418
+ }, { clock });
419
+ await eventStore.append(event);
420
+ committedEvent = event;
421
+ return { event };
422
+ } catch (error) {
423
+ if (prepared) await reconcile('commit', error);
424
+ // Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
425
+ if (commit && envelope.type !== 'event') {
426
+ try {
427
+ if (!current) await vfs.remove(envelope.path, { version: commit.version });
428
+ else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
429
+ } catch (rollbackError) {
430
+ // The intent stays, so the next commit on this path or recover()
431
+ // finishes the rollback.
432
+ return { keepIntent: true, response: failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]) };
433
+ }
434
+ }
435
+ // A concurrent request with the same key may have won the commit (VFS
436
+ // compare-and-swap or duplicate event_id); answer with its result.
437
+ try {
438
+ const winner = await settled();
439
+ if (winner) return { response: winner };
440
+ } catch {}
441
+ const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
442
+ return { response: failure(code, envelope, operationId, [`Commit failed: ${error.message}`]) };
307
443
  }
444
+ };
308
445
 
309
- event = createCanonicalEvent({
310
- // `event` envelopes have no VFS compare-and-swap, so a key-derived id
311
- // lets the event store reject a concurrent request with the same key.
312
- event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
313
- workspace_id: envelope.workspace_id,
314
- primitive_id: definition.qualified_type,
315
- primitive_version: definition.primitive_version || 1,
316
- action,
317
- subject: envelope.path,
318
- principal,
319
- operation_id: operationId,
320
- idempotency_key: envelope.idempotency_key,
321
- request_hash: requestHash,
322
- before_hash: current?.hash || null,
323
- after_hash: commit?.hash || null,
324
- changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
325
- resource_status: prepared ? 'prepared' : null,
326
- payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
327
- }, { clock });
328
- await eventStore.append(event);
446
+ // Everything but an `event` envelope writes the VFS, so it commits under a
447
+ // durable intent holding the prior content (spec §6.3).
448
+ const intent = envelope.type === 'event' ? null : {
449
+ version: 1, operation_id: operationId, event_id: eventId, workspace_id: envelope.workspace_id,
450
+ path: envelope.path, action, idempotency_key: envelope.idempotency_key, request_hash: requestHash,
451
+ before: current ? { exists: true, digest: digestBytes(current.bytes), bytes: current.bytes.toString('base64') } : { exists: false, digest: null },
452
+ after_digest: envelope.type === 'delete' ? null : digestBytes(Buffer.from(nextContent, 'utf8')),
453
+ started_at: clock(),
454
+ };
455
+ const warnings = [];
456
+ let outcome;
457
+ try {
458
+ outcome = intent ? await intentJournal.run(envelope.path, intent, commitAndAppend, { resolve: resolveIntent }) : await commitAndAppend();
329
459
  } catch (error) {
330
- if (prepared) await reconcile('commit', error);
331
- // Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
332
- if (commit && envelope.type !== 'event') {
333
- try {
334
- if (!current) await vfs.remove(envelope.path, { version: commit.version });
335
- else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
336
- } catch (rollbackError) {
337
- return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
338
- }
460
+ // Before the commit (lock, intent write, an earlier writer's recovery):
461
+ // nothing happened. After it, the event already says it happened.
462
+ if (!committedEvent) {
463
+ if (prepared) await reconcile('commit', error);
464
+ return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}`]);
339
465
  }
340
- // A concurrent request with the same key may have won the commit (VFS
341
- // compare-and-swap or duplicate event_id); answer with its result.
342
- try {
343
- const winner = await settled();
344
- if (winner) return winner;
345
- } catch {}
346
- const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
347
- return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
466
+ outcome = { event: committedEvent };
467
+ warnings.push(`Commit intent cleanup failed: ${error.message}`);
348
468
  }
469
+ if (outcome.response) return outcome.response;
470
+ const { event } = outcome;
349
471
 
350
472
  // The appended event is the commit point (spec §6.3): nothing after it may
351
473
  // roll the mutation back. Post-commit failures become warnings.
352
- const warnings = [];
353
474
  if (resourceCoordinator?.finalize) {
354
475
  try { await resourceCoordinator.finalize({ resourceState, event }); }
355
476
  catch (error) {
@@ -375,5 +496,5 @@ export function createKernel(options = {}) {
375
496
  return response;
376
497
  }
377
498
 
378
- return { execute, registry: registrySet, validator };
499
+ return { execute, recover, registry: registrySet, validator };
379
500
  }
package/src/vfs.mjs CHANGED
@@ -4,10 +4,17 @@ import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import { isSafeDocumentPath } from './registry.mjs';
6
6
  import { withFileLock } from './file-lock.mjs';
7
+ import { fsyncDirectory, writeFileSyncDurable } from './durable-fs.mjs';
8
+ import { FileIntentJournal } from './intents.mjs';
7
9
 
8
10
  /** Reserved FileSystemVfs directory for per-path write locks; never a document path. */
9
11
  export const VFS_LOCK_DIR = '.ssss-locks';
10
12
 
13
+ /** Reserved FileSystemVfs directory for commit intents (spec §6.3); never a document path. */
14
+ export const VFS_INTENT_DIR = '.ssss-intents';
15
+
16
+ const RESERVED_DIRS = new Set([VFS_LOCK_DIR, VFS_INTENT_DIR]);
17
+
11
18
  export class VfsConflictError extends Error {
12
19
  constructor(message) { super(message); this.name = 'VfsConflictError'; this.code = 'SSSS_VFS_CONFLICT'; }
13
20
  }
@@ -86,6 +93,14 @@ export class FileSystemVfs {
86
93
  fs.mkdirSync(root, { recursive: true });
87
94
  this.root = fs.realpathSync(root);
88
95
  this.lockOptions = options.lock || {};
96
+ this.intentLockOptions = options.intentLock || {};
97
+ }
98
+
99
+ #journal = null;
100
+
101
+ /** The commit intent journal co-located with this vault (spec §6.3, §6.7). */
102
+ intentJournal() {
103
+ return (this.#journal ||= new FileIntentJournal(path.join(this.root, VFS_INTENT_DIR), { lock: this.intentLockOptions }));
89
104
  }
90
105
 
91
106
  // Writers to one path serialize on a lock so precondition checks and commits
@@ -102,7 +117,7 @@ export class FileSystemVfs {
102
117
  #resolve(vfsPath, { allowMissing = true } = {}) {
103
118
  if (!isSafeDocumentPath(vfsPath)) throw new VfsPathError(`Unsafe VFS path '${vfsPath}'.`);
104
119
  // Case-insensitive: on APFS/NTFS any casing reaches the same directory.
105
- if (vfsPath.split('/')[0].toLowerCase() === VFS_LOCK_DIR) throw new VfsPathError(`VFS path '${vfsPath}' is reserved.`);
120
+ if (RESERVED_DIRS.has(vfsPath.split('/')[0].toLowerCase())) throw new VfsPathError(`VFS path '${vfsPath}' is reserved.`);
106
121
  const target = path.resolve(this.root, ...vfsPath.split('/'));
107
122
  if (target === this.root || !target.startsWith(`${this.root}${path.sep}`)) {
108
123
  throw new VfsPathError(`VFS path '${vfsPath}' escapes the root.`);
@@ -149,7 +164,7 @@ export class FileSystemVfs {
149
164
  while (stack.length) {
150
165
  const dir = stack.pop();
151
166
  for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
152
- if (dir === this.root && entry.name === VFS_LOCK_DIR) continue;
167
+ if (dir === this.root && RESERVED_DIRS.has(entry.name)) continue;
153
168
  const absolute = path.join(dir, entry.name);
154
169
  const relative = path.relative(this.root, absolute).split(path.sep).join('/');
155
170
  if (entry.isSymbolicLink()) throw new VfsPathError(`Symlinked VFS entry is forbidden: '${relative}'.`);
@@ -177,8 +192,11 @@ export class FileSystemVfs {
177
192
  const nextBytes = asBuffer(bytes);
178
193
  const temp = path.join(path.dirname(target), `.${path.basename(target)}.${crypto.randomUUID()}.tmp`);
179
194
  try {
180
- fs.writeFileSync(temp, nextBytes, { flag: 'wx', mode: 0o600 });
195
+ // Flushed before the rename and the directory after it, so the commit is
196
+ // on disk before the kernel appends its event (spec §6.3).
197
+ writeFileSyncDurable(temp, nextBytes);
181
198
  fs.renameSync(temp, target);
199
+ fsyncDirectory(path.dirname(target));
182
200
  } finally {
183
201
  fs.rmSync(temp, { force: true });
184
202
  }
@@ -201,6 +219,7 @@ export class FileSystemVfs {
201
219
  checkPrecondition(current, precondition);
202
220
  if (!target || !current) return { path: vfsPath, removed: false };
203
221
  fs.rmSync(target);
222
+ fsyncDirectory(path.dirname(target));
204
223
  return { path: vfsPath, removed: true, previous_hash: current.hash };
205
224
  });
206
225
  }
package/types/kernel.d.ts CHANGED
@@ -40,16 +40,45 @@ export interface SsssResponse {
40
40
  repair?: { field_errors: Array<{ field: string; issue: string }> };
41
41
  }
42
42
 
43
+ /** How recovery decided one commit intent (spec §6.3). */
44
+ export type SsssIntentOutcome = 'committed' | 'rolled_back' | 'discarded' | 'conflict' | 'failed' | 'in_flight';
45
+
46
+ export interface SsssRecoveryResult {
47
+ path: string | null;
48
+ operation_id: string | null;
49
+ outcome: SsssIntentOutcome;
50
+ reason?: string;
51
+ /** Journal-relative location of an intent moved aside as unresolvable. */
52
+ quarantined?: string;
53
+ }
54
+
43
55
  export interface SsssKernel {
44
56
  execute(command: SsssCommand, context: { principal: VerifiedPrincipal; [key: string]: unknown }): Promise<SsssResponse>;
57
+ /** Resolve commit intents whose writer is gone; report live ones as `in_flight`. */
58
+ recover(): Promise<SsssRecoveryResult[]>;
45
59
  registry: unknown;
46
60
  validator: unknown;
47
61
  }
48
62
 
63
+ /** Commit intent journal (spec §6.7). */
64
+ export interface SsssIntentJournal {
65
+ run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
66
+ recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
67
+ }
68
+
49
69
  export const ERROR_CODES: readonly SsssErrorCode[];
50
70
  export function canonicalRequestHash(command: SsssCommand, principal: VerifiedPrincipal): string;
51
71
  export class MemoryIdempotencyStore {
52
72
  get(workspaceId: string, idempotencyKey: string): Promise<unknown>;
53
73
  put(workspaceId: string, idempotencyKey: string, value: unknown): Promise<void>;
54
74
  }
75
+ export class MemoryIntentJournal implements SsssIntentJournal {
76
+ run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
77
+ recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
78
+ }
79
+ export class FileIntentJournal implements SsssIntentJournal {
80
+ constructor(root: string, options?: { lock?: { timeoutMs?: number; staleMs?: number } });
81
+ run<T>(path: string, intent: Record<string, unknown>, commit: () => Promise<T>, options: { resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }> }): Promise<T>;
82
+ recover(resolve: (intent: Record<string, unknown>) => Promise<{ outcome: SsssIntentOutcome; reason?: string }>): Promise<SsssRecoveryResult[]>;
83
+ }
55
84
  export function createKernel(options: Record<string, unknown>): SsssKernel;