@gregiteen/ssss-cli 0.9.0 → 0.9.2

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/events.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  export function createCanonicalEvent(input, options = {}) {
7
8
  const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
@@ -33,12 +34,12 @@ export function createCanonicalEvent(input, options = {}) {
33
34
 
34
35
  export class MemoryEventStore {
35
36
  #events = [];
37
+ #ids = new Set();
36
38
  async append(event) {
37
- if (this.#events.some((existing) => existing.event_id === event.event_id)) {
38
- throw new Error(`Duplicate event_id '${event.event_id}'.`);
39
- }
39
+ if (this.#ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
40
40
  const frozen = structuredClone(event);
41
41
  this.#events.push(frozen);
42
+ this.#ids.add(frozen.event_id);
42
43
  return structuredClone(frozen);
43
44
  }
44
45
  async *replay(options = {}) {
@@ -51,24 +52,96 @@ export class MemoryEventStore {
51
52
  async size() { return this.#events.length; }
52
53
  }
53
54
 
55
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
56
+ const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | NOFOLLOW;
57
+
58
+ function openLog(file, flags, mode) {
59
+ try { return fs.openSync(file, flags, mode); }
60
+ catch (error) {
61
+ if (error.code === 'ELOOP') throw new Error('Symlinked event logs are forbidden.');
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ function sameStat(a, b) {
67
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
68
+ }
69
+
70
+ /** Read every event_id in a log, bounded to the bytes covered by the returned stat. */
71
+ function loadIndex(file) {
72
+ const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
73
+ try {
74
+ const stat = fs.fstatSync(fd);
75
+ const bytes = Buffer.alloc(stat.size);
76
+ let offset = 0;
77
+ while (offset < bytes.length) {
78
+ const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
79
+ if (read === 0) break;
80
+ offset += read;
81
+ }
82
+ const ids = new Set();
83
+ for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
84
+ if (line.trim()) ids.add(JSON.parse(line).event_id);
85
+ }
86
+ return { ids, stat };
87
+ } finally { fs.closeSync(fd); }
88
+ }
89
+
54
90
  export class JsonlEventStore {
55
- constructor(root) {
91
+ // Per-log index of event_ids, built on the first append to that log. Each
92
+ // index records the stat of the bytes it covers; any change (another store
93
+ // instance or process appended, or the file was replaced) rebuilds it.
94
+ // Appenders serialize on `<log>.lock` so the duplicate check and the append
95
+ // are atomic across processes.
96
+ #indexes = new Map();
97
+ constructor(root, options = {}) {
56
98
  fs.mkdirSync(root, { recursive: true });
57
99
  this.root = fs.realpathSync(root);
100
+ this.lockOptions = options.lock || {};
58
101
  }
59
102
  #file(workspaceId) {
60
103
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
61
104
  return path.join(this.root, `${workspaceId}.jsonl`);
62
105
  }
106
+ #index(file) {
107
+ let stat;
108
+ try { stat = fs.lstatSync(file); }
109
+ catch (error) {
110
+ if (error.code !== 'ENOENT') throw error;
111
+ this.#indexes.delete(file);
112
+ return { ids: new Set(), stat: null };
113
+ }
114
+ if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
115
+ const cached = this.#indexes.get(file);
116
+ if (cached && sameStat(cached.stat, stat)) return cached;
117
+ const index = loadIndex(file);
118
+ this.#indexes.set(file, index);
119
+ return index;
120
+ }
63
121
  async append(event) {
64
122
  const file = this.#file(event.workspace_id);
65
- if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
66
- if (fs.existsSync(file)) {
67
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
68
- if (line.trim() && JSON.parse(line).event_id === event.event_id) throw new Error(`Duplicate event_id '${event.event_id}'.`);
69
- }
123
+ return withFileLock(`${file}.lock`, () => this.#appendLocked(file, event), this.lockOptions);
124
+ }
125
+ #appendLocked(file, event) {
126
+ const index = this.#index(file);
127
+ if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
128
+ const line = `${JSON.stringify(event)}\n`;
129
+ this.#indexes.delete(file);
130
+ const fd = openLog(file, APPEND_FLAGS, 0o600);
131
+ let after;
132
+ try {
133
+ fs.writeFileSync(fd, line);
134
+ after = fs.fstatSync(fd);
135
+ } finally { fs.closeSync(fd); }
136
+ // Keep the index only when the log grew by exactly this line on the same
137
+ // file; otherwise a writer that skips the lock interleaved, and the next
138
+ // append rebuilds.
139
+ const before = index.stat;
140
+ const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
141
+ if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
142
+ index.ids.add(event.event_id);
143
+ this.#indexes.set(file, { ids: index.ids, stat: after });
70
144
  }
71
- fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
72
145
  return structuredClone(event);
73
146
  }
74
147
  async *replay(options = {}) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Cross-process exclusive lock files for the reference file adapters.
3
+ *
4
+ * A lock is a file created with O_EXCL and removed by its holder. A lock left
5
+ * behind by a crashed holder is broken once the holder is known to be dead
6
+ * (same host, process gone) or the lock is older than `staleMs`.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import crypto from 'node:crypto';
12
+
13
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
14
+ const CREATE_EXCLUSIVE = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | NOFOLLOW;
15
+ const HOST = os.hostname();
16
+
17
+ export const FILE_LOCK_DEFAULTS = Object.freeze({ timeoutMs: 40_000, staleMs: 30_000 });
18
+
19
+ export class FileLockTimeoutError extends Error {
20
+ constructor(lockPath) {
21
+ super(`Timed out waiting for lock '${path.basename(lockPath)}'.`);
22
+ this.name = 'FileLockTimeoutError';
23
+ this.code = 'SSSS_LOCK_TIMEOUT';
24
+ }
25
+ }
26
+
27
+ function notRegular(lockPath) {
28
+ return new Error(`Lock '${path.basename(lockPath)}' is not a regular file.`);
29
+ }
30
+
31
+ function readHolder(lockPath) {
32
+ try {
33
+ const fd = fs.openSync(lockPath, fs.constants.O_RDONLY | NOFOLLOW);
34
+ try { return JSON.parse(fs.readFileSync(fd, 'utf8')); } finally { fs.closeSync(fd); }
35
+ } catch { return null; }
36
+ }
37
+
38
+ function holderIsDead(holder) {
39
+ if (holder?.host !== HOST || !Number.isInteger(holder.pid) || holder.pid === process.pid) return false;
40
+ try { process.kill(holder.pid, 0); return false; }
41
+ catch (error) { return error.code === 'ESRCH'; }
42
+ }
43
+
44
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs;
45
+
46
+ /** Create the lock and return its stat (proof of ownership), or null when it is held. */
47
+ function tryAcquire(lockPath, createDir) {
48
+ let fd;
49
+ try { fd = fs.openSync(lockPath, CREATE_EXCLUSIVE, 0o600); }
50
+ catch (error) {
51
+ if (error.code === 'EEXIST') return null;
52
+ if (error.code === 'ELOOP') throw notRegular(lockPath);
53
+ if (error.code === 'ENOENT' && createDir) {
54
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
55
+ return tryAcquire(lockPath, false);
56
+ }
57
+ throw error;
58
+ }
59
+ try {
60
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: HOST, acquired_at: new Date().toISOString() }));
61
+ return fs.fstatSync(fd);
62
+ } catch (error) {
63
+ fs.rmSync(lockPath, { force: true });
64
+ throw error;
65
+ } finally { fs.closeSync(fd); }
66
+ }
67
+
68
+ function breakIfStale(lockPath, staleMs) {
69
+ let stat;
70
+ try { stat = fs.lstatSync(lockPath); }
71
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
72
+ if (!stat.isFile()) throw notRegular(lockPath);
73
+ if (Date.now() - stat.mtimeMs < staleMs && !holderIsDead(readHolder(lockPath))) return;
74
+ // Move the lock aside so only one breaker wins. If a live holder replaced it
75
+ // after the stat above, the moved file is a different inode: put it back.
76
+ const aside = `${lockPath}.${crypto.randomUUID()}.stale`;
77
+ try { fs.renameSync(lockPath, aside); }
78
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
79
+ const moved = fs.lstatSync(aside);
80
+ if (moved.ino !== stat.ino || moved.dev !== stat.dev) {
81
+ try { fs.linkSync(aside, lockPath); } catch {}
82
+ }
83
+ fs.rmSync(aside, { force: true });
84
+ }
85
+
86
+ // Remove the lock only if it is still the file we created; a lock broken as
87
+ // stale and re-taken by another holder is a different file.
88
+ function release(lockPath, owned) {
89
+ try {
90
+ if (sameFile(fs.lstatSync(lockPath), owned)) fs.unlinkSync(lockPath);
91
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
92
+ }
93
+
94
+ /**
95
+ * Run `fn` while holding the lock file at `lockPath`. With `createDir`, the
96
+ * lock's directory is created (mode 0700) when it is missing.
97
+ */
98
+ export async function withFileLock(lockPath, fn, options = {}) {
99
+ const timeoutMs = options.timeoutMs ?? FILE_LOCK_DEFAULTS.timeoutMs;
100
+ const staleMs = options.staleMs ?? FILE_LOCK_DEFAULTS.staleMs;
101
+ const deadline = Date.now() + timeoutMs;
102
+ let owned = tryAcquire(lockPath, options.createDir);
103
+ for (let delay = 1; !owned; delay = Math.min(delay * 2, 50)) {
104
+ breakIfStale(lockPath, staleMs);
105
+ owned = tryAcquire(lockPath, options.createDir);
106
+ if (owned) break;
107
+ if (Date.now() >= deadline) throw new FileLockTimeoutError(lockPath);
108
+ await new Promise((resolve) => setTimeout(resolve, delay * (1 + Math.random())));
109
+ }
110
+ try { return await fn(); }
111
+ finally { release(lockPath, owned); }
112
+ }
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
7
8
 
@@ -16,7 +17,11 @@ export class MemoryIdempotencyStore {
16
17
  }
17
18
 
18
19
  export class FileIdempotencyStore {
19
- constructor(root) { fs.mkdirSync(root, { recursive: true }); this.root = fs.realpathSync(root); }
20
+ constructor(root, options = {}) {
21
+ fs.mkdirSync(root, { recursive: true });
22
+ this.root = fs.realpathSync(root);
23
+ this.lockOptions = options.lock || {};
24
+ }
20
25
  #file(workspaceId, idempotencyKey) {
21
26
  const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
22
27
  return path.join(this.root, `${digest}.json`);
@@ -31,12 +36,15 @@ export class FileIdempotencyStore {
31
36
  }
32
37
  async put(workspaceId, idempotencyKey, value) {
33
38
  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 }); }
39
+ // Serialize writers of one key so a second put fails instead of replacing the first.
40
+ return withFileLock(`${file}.lock`, () => {
41
+ if (fs.existsSync(file)) throw new Error('Idempotency entry already exists.');
42
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
43
+ try {
44
+ fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
45
+ fs.renameSync(temp, file);
46
+ } finally { fs.rmSync(temp, { force: true }); }
47
+ }, this.lockOptions);
40
48
  }
41
49
  }
42
50
 
package/src/leases.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import { withFileLock } from './file-lock.mjs';
5
6
 
6
7
  function validateLeaseInput(value) {
7
8
  for (const field of ['workspace_id', 'target', 'principal_id', 'operation_id']) {
@@ -46,15 +47,25 @@ export class MemoryLeaseStore {
46
47
  }
47
48
  }
48
49
 
50
+ export function leaseStatePath(root, workspaceId, target) {
51
+ const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
52
+ return path.join(root, `${hash}.lease.json`);
53
+ }
54
+
49
55
  export class FileLeaseStore {
50
56
  constructor(root, options = {}) {
51
57
  fs.mkdirSync(root, { recursive: true });
52
58
  this.root = fs.realpathSync(root);
53
59
  this.clock = options.clock || (() => Date.now());
60
+ this.lockOptions = options.lock || {};
54
61
  }
55
62
  #file(workspaceId, target) {
56
- const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
57
- return path.join(this.root, `${hash}.lease.json`);
63
+ return leaseStatePath(this.root, workspaceId, target);
64
+ }
65
+ // Mutations of one lease serialize on a lock so "at most one active lease"
66
+ // holds across processes.
67
+ #locked(input, fn) {
68
+ return withFileLock(`${this.#file(input.workspace_id, input.target)}.lock`, fn, this.lockOptions);
58
69
  }
59
70
  #load(workspaceId, target) {
60
71
  const file = this.#file(workspaceId, target);
@@ -74,11 +85,13 @@ export class FileLeaseStore {
74
85
  }
75
86
  async acquire(input, ttlMs = 30_000) {
76
87
  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 };
88
+ return this.#locked(input, () => {
89
+ const existing = this.#load(input.workspace_id, input.target);
90
+ if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
91
+ const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
92
+ this.#write(lease);
93
+ return { ...lease };
94
+ });
82
95
  }
83
96
  async verify(input) {
84
97
  const lease = this.#load(input.workspace_id, input.target);
@@ -90,17 +103,21 @@ export class FileLeaseStore {
90
103
  return { valid: true, lease };
91
104
  }
92
105
  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;
106
+ return this.#locked(input, async () => {
107
+ const verified = await this.verify(input);
108
+ if (!verified.valid) throw new Error(verified.reason);
109
+ verified.lease.expires_at = this.clock() + ttlMs;
110
+ this.#write(verified.lease);
111
+ return verified.lease;
112
+ });
98
113
  }
99
114
  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 };
115
+ return this.#locked(input, async () => {
116
+ const verified = await this.verify(input);
117
+ if (!verified.valid) throw new Error(verified.reason);
118
+ fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
119
+ return { released: true, lease_id: input.lease_id };
120
+ });
104
121
  }
105
122
  }
106
123
 
@@ -117,9 +134,53 @@ export async function runLeaseContract(store, { clock } = {}) {
117
134
  record('acquire returns bound lease', acquired.lease_id && acquired.principal_id === input.principal_id);
118
135
  record('matching lease verifies', (await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
119
136
  record('mismatched owner fails closed', !(await store.verify({ ...input, principal_id: 'other', lease_id: acquired.lease_id })).valid);
137
+ record('mismatched lease_id fails closed', !(await store.verify({ ...input, lease_id: crypto.randomUUID() })).valid);
138
+ record('missing lease fails closed', !(await store.verify({
139
+ workspace_id: input.workspace_id,
140
+ target: `missing/${crypto.randomUUID()}.md`,
141
+ principal_id: input.principal_id,
142
+ operation_id: input.operation_id,
143
+ lease_id: acquired.lease_id,
144
+ })).valid);
120
145
  const renewed = await store.renew({ ...input, lease_id: acquired.lease_id }, 20_000);
121
146
  record('renew extends expiry', renewed.expires_at >= acquired.expires_at);
122
147
  await store.release({ ...input, lease_id: acquired.lease_id });
123
148
  record('released lease is missing', !(await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
149
+
150
+ // Controllable clock proves expiry fail-closed for any store that accepts injection.
151
+ if (typeof store.clock === 'function' || clock) {
152
+ let now = 1_000_000;
153
+ const timed = store.constructor === MemoryLeaseStore
154
+ ? new MemoryLeaseStore({ clock: () => now })
155
+ : store.constructor === FileLeaseStore
156
+ ? new FileLeaseStore(path.join(store.root, `expiry-${crypto.randomUUID()}`), { clock: () => now })
157
+ : null;
158
+ if (timed) {
159
+ const timedInput = { ...input, target: `rules/expiry-${crypto.randomUUID()}.md` };
160
+ const short = await timed.acquire(timedInput, 50);
161
+ now = short.expires_at + 1;
162
+ record('expired lease fails closed', !(await timed.verify({ ...timedInput, lease_id: short.lease_id })).valid);
163
+ }
164
+ }
165
+
166
+ // Filesystem stores must fail closed on corrupt/unreadable state.
167
+ if (store instanceof FileLeaseStore) {
168
+ const corruptInput = {
169
+ workspace_id: `ws-${crypto.randomUUID()}`,
170
+ target: `rules/corrupt-${crypto.randomUUID()}.md`,
171
+ principal_id: 'principal',
172
+ operation_id: 'operation',
173
+ };
174
+ const good = await store.acquire(corruptInput, 30_000);
175
+ fs.writeFileSync(leaseStatePath(store.root, corruptInput.workspace_id, corruptInput.target), '{not-json', { mode: 0o600 });
176
+ let unreadable = false;
177
+ try {
178
+ await store.verify({ ...corruptInput, lease_id: good.lease_id });
179
+ } catch (error) {
180
+ unreadable = /unreadable/i.test(error.message);
181
+ }
182
+ record('unreadable lease state fails closed', unreadable);
183
+ }
184
+
124
185
  return { passed: checks.every((check) => check.passed), checks };
125
186
  }
@@ -1,4 +1,4 @@
1
- /** Replayable, cursor-aware SSSS projection coordinator. */
1
+ /** Replayable, cursor-aware SSSS projection coordinator and host adapter examples. */
2
2
  import crypto from 'node:crypto';
3
3
 
4
4
  function stable(value) {
@@ -11,6 +11,77 @@ export function projectionHash(value) {
11
11
  return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex')}`;
12
12
  }
13
13
 
14
+ /**
15
+ * Reference projection destination adapters.
16
+ * Hosts own durable destination writes; these prove the shared apply/reset/snapshot contract.
17
+ */
18
+ export function createSqlProjectionAdapter({ table = 'ssss_events' } = {}) {
19
+ const rows = new Map();
20
+ return {
21
+ id: `sql:${table}`,
22
+ kind: 'sql',
23
+ apply: async (event) => {
24
+ rows.set(event.event_id, {
25
+ event_id: event.event_id,
26
+ subject: event.subject,
27
+ action: event.action,
28
+ after_hash: event.after_hash,
29
+ workspace_id: event.workspace_id,
30
+ });
31
+ },
32
+ reset: async () => rows.clear(),
33
+ snapshot: async () => Object.fromEntries([...rows.entries()].sort()),
34
+ };
35
+ }
36
+
37
+ export function createSearchProjectionAdapter({ index = 'ssss' } = {}) {
38
+ const documents = new Map();
39
+ return {
40
+ id: `search:${index}`,
41
+ kind: 'search',
42
+ apply: async (event) => {
43
+ documents.set(event.subject, {
44
+ subject: event.subject,
45
+ terms: [event.action, event.primitive_id, event.subject].filter(Boolean).join(' '),
46
+ after_hash: event.after_hash,
47
+ });
48
+ },
49
+ reset: async () => documents.clear(),
50
+ snapshot: async () => Object.fromEntries([...documents.entries()].sort()),
51
+ };
52
+ }
53
+
54
+ export function createQueueProjectionAdapter({ topic = 'ssss.mutations' } = {}) {
55
+ const messages = [];
56
+ return {
57
+ id: `queue:${topic}`,
58
+ kind: 'queue',
59
+ apply: async (event) => {
60
+ messages.push({ topic, event_id: event.event_id, subject: event.subject, action: event.action });
61
+ },
62
+ reset: async () => { messages.length = 0; },
63
+ snapshot: async () => messages.map((message) => ({ ...message })),
64
+ };
65
+ }
66
+
67
+ export function createViewModelProjectionAdapter({ view = 'detail' } = {}) {
68
+ const models = new Map();
69
+ return {
70
+ id: `view:${view}`,
71
+ kind: 'view_model',
72
+ apply: async (event) => {
73
+ models.set(event.subject, {
74
+ subject: event.subject,
75
+ version: event.after_hash || event.before_hash || null,
76
+ last_action: event.action,
77
+ principal_id: event.principal?.id || null,
78
+ });
79
+ },
80
+ reset: async () => models.clear(),
81
+ snapshot: async () => Object.fromEntries([...models.entries()].sort()),
82
+ };
83
+ }
84
+
14
85
  export class ProjectionCoordinator {
15
86
  constructor({ eventStore, cursorStore = new Map() } = {}) {
16
87
  if (!eventStore) throw new Error('ProjectionCoordinator requires an eventStore.');
package/src/vfs.mjs CHANGED
@@ -3,6 +3,10 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import { isSafeDocumentPath } from './registry.mjs';
6
+ import { withFileLock } from './file-lock.mjs';
7
+
8
+ /** Reserved FileSystemVfs directory for per-path write locks; never a document path. */
9
+ export const VFS_LOCK_DIR = '.ssss-locks';
6
10
 
7
11
  export class VfsConflictError extends Error {
8
12
  constructor(message) { super(message); this.name = 'VfsConflictError'; this.code = 'SSSS_VFS_CONFLICT'; }
@@ -77,14 +81,28 @@ export class MemoryVfs {
77
81
  }
78
82
 
79
83
  export class FileSystemVfs {
80
- constructor(root) {
84
+ constructor(root, options = {}) {
81
85
  if (!root) throw new Error('FileSystemVfs requires a root directory.');
82
86
  fs.mkdirSync(root, { recursive: true });
83
87
  this.root = fs.realpathSync(root);
88
+ this.lockOptions = options.lock || {};
89
+ }
90
+
91
+ // Writers to one path serialize on a lock so precondition checks and commits
92
+ // are atomic across processes.
93
+ #locked(vfsPath, fn) {
94
+ const dir = path.join(this.root, VFS_LOCK_DIR);
95
+ let stat = null;
96
+ try { stat = fs.lstatSync(dir); } catch (error) { if (error.code !== 'ENOENT') throw error; }
97
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new VfsPathError(`VFS lock directory '${VFS_LOCK_DIR}' must be a real directory.`);
98
+ const name = crypto.createHash('sha256').update(vfsPath).digest('hex');
99
+ return withFileLock(path.join(dir, `${name}.lock`), fn, { ...this.lockOptions, createDir: true });
84
100
  }
85
101
 
86
102
  #resolve(vfsPath, { allowMissing = true } = {}) {
87
103
  if (!isSafeDocumentPath(vfsPath)) throw new VfsPathError(`Unsafe VFS path '${vfsPath}'.`);
104
+ // 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.`);
88
106
  const target = path.resolve(this.root, ...vfsPath.split('/'));
89
107
  if (target === this.root || !target.startsWith(`${this.root}${path.sep}`)) {
90
108
  throw new VfsPathError(`VFS path '${vfsPath}' escapes the root.`);
@@ -131,6 +149,7 @@ export class FileSystemVfs {
131
149
  while (stack.length) {
132
150
  const dir = stack.pop();
133
151
  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;
134
153
  const absolute = path.join(dir, entry.name);
135
154
  const relative = path.relative(this.root, absolute).split(path.sep).join('/');
136
155
  if (entry.isSymbolicLink()) throw new VfsPathError(`Symlinked VFS entry is forbidden: '${relative}'.`);
@@ -144,6 +163,10 @@ export class FileSystemVfs {
144
163
 
145
164
  async writeAtomic(vfsPath, bytes, precondition = {}) {
146
165
  const target = this.#resolve(vfsPath);
166
+ return this.#locked(vfsPath, () => this.#commit(vfsPath, target, bytes, precondition));
167
+ }
168
+
169
+ async #commit(vfsPath, target, bytes, precondition) {
147
170
  const current = await this.read(vfsPath);
148
171
  checkPrecondition(current, precondition);
149
172
  fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -171,12 +194,15 @@ export class FileSystemVfs {
171
194
  }
172
195
 
173
196
  async remove(vfsPath, precondition = {}) {
174
- const target = this.#resolve(vfsPath, { allowMissing: false });
175
- const current = await this.read(vfsPath);
176
- checkPrecondition(current, precondition);
177
- if (!target || !current) return { path: vfsPath, removed: false };
178
- fs.rmSync(target);
179
- return { path: vfsPath, removed: true, previous_hash: current.hash };
197
+ this.#resolve(vfsPath);
198
+ return this.#locked(vfsPath, async () => {
199
+ const target = this.#resolve(vfsPath, { allowMissing: false });
200
+ const current = await this.read(vfsPath);
201
+ checkPrecondition(current, precondition);
202
+ if (!target || !current) return { path: vfsPath, removed: false };
203
+ fs.rmSync(target);
204
+ return { path: vfsPath, removed: true, previous_hash: current.hash };
205
+ });
180
206
  }
181
207
  }
182
208