@gregiteen/ssss-cli 0.9.1 → 0.9.3

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 CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.3] - 2026-09-24
11
+ ### Fixed
12
+ - Two processes sending an `event` envelope with the same idempotency key at the
13
+ same moment could both append an event, because the kernel records the result
14
+ only after committing and `event` envelopes have no VFS compare-and-swap. The
15
+ kernel now derives an `event` envelope's `event_id` from `(workspace_id,
16
+ idempotency_key)` (`idempotentEventId`, UUIDv5), so the event store rejects
17
+ the second append. Any request that loses at commit re-reads the idempotency
18
+ store and returns the winner's result as a replay, or an idempotency conflict
19
+ when its request differs. If a process dies after appending an event but
20
+ before recording its result, retries of that key now fail instead of
21
+ appending a second event.
22
+
23
+ ### Added
24
+ - `idempotentEventId(workspaceId, idempotencyKey)` in `@gregiteen/ssss-cli/events`.
25
+ - Conformance checks racing six kernel processes on one `event` idempotency key,
26
+ with identical and with differing content.
27
+
28
+ ## [0.9.2] - 2026-09-24
29
+ ### Fixed
30
+ - The file adapters are now safe to share between processes. Each one checked
31
+ state and then wrote it as two steps, so concurrent processes could both pass
32
+ the check: `FileSystemVfs` could lose an update behind a passed
33
+ compare-and-swap, `FileLeaseStore` could grant the same lease twice (spec §7),
34
+ `FileIdempotencyStore.put` could replace an existing entry, and
35
+ `JsonlEventStore` could append one `event_id` twice. Each check-then-write now
36
+ runs under an exclusive lock file (`O_EXCL`). A lock whose holder process has
37
+ exited is broken immediately; any lock older than 30 s is treated as stale;
38
+ a waiter gives up after 40 s with `SSSS_LOCK_TIMEOUT`. Adapters accept
39
+ `{ lock: { timeoutMs, staleMs } }`. `FileSystemVfs` keeps its locks in a
40
+ reserved `.ssss-locks/` directory that VFS paths cannot address and `list()`
41
+ skips. Locking costs about 0.5 ms per write on APFS.
42
+ - `scripts/release.sh` bumps `package.json` and `package-lock.json` and moves the
43
+ `[Unreleased]` entries into the new version's section instead of inserting a
44
+ stub above `[Unreleased]`.
45
+
46
+ ### Added
47
+ - `ssss help concurrency`.
48
+ - Conformance checks that race six processes against each file adapter, plus
49
+ lock checks for dead holders, stale locks, timeouts, symlinked locks, and the
50
+ reserved VFS lock directory.
51
+ - Git tags `v0.8.0` and `v0.9.0` on commits whose package files match the
52
+ published npm tarballs byte-for-byte. Both versions were published from
53
+ uncommitted trees, so no existing commit matched. The push skill now publishes
54
+ from a clean clone of the release tag.
55
+
10
56
  ## [0.9.1] - 2026-09-24
11
57
  ### Fixed
12
58
  - `JsonlEventStore.append` no longer re-reads and re-parses the whole
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.1
1
+ 0.9.3
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.9.1"
109
+ "exporter": "@gregiteen/ssss-cli@0.9.3"
110
110
  }
111
111
  },
112
112
  "files": [
@@ -0,0 +1,58 @@
1
+ # File adapter concurrency
2
+
3
+ The reference file adapters can be shared by several processes on one
4
+ filesystem. Each adapter's check-then-write step runs under a lock file, so at
5
+ most one racer wins:
6
+
7
+ | Adapter | Guarantee across processes | Lock file |
8
+ |---------|----------------------------|-----------|
9
+ | `JsonlEventStore.append` | An `event_id` is appended at most once. | `<events>/<workspace>.jsonl.lock` |
10
+ | `FileSystemVfs.writeAtomic` / `remove` | `ifAbsent`, `ifPresent`, and `version` preconditions are compare-and-swap. | `<vault>/.ssss-locks/<sha256(path)>.lock` |
11
+ | `FileLeaseStore.acquire` / `renew` / `release` | At most one active lease per `(workspace_id, target)` (spec §7). | `<leases>/<sha256>.lease.json.lock` |
12
+ | `FileIdempotencyStore.put` | A second `put` for a key fails instead of replacing the first. | `<idempotency>/<sha256>.json.lock` |
13
+
14
+ `.ssss-locks/` is reserved: VFS paths under it are rejected and `list()` skips it.
15
+
16
+ ## Same idempotency key from two processes
17
+
18
+ The kernel records a request's result only after it commits, so two processes
19
+ can both miss the stored result for one key. The commit step then decides:
20
+
21
+ - `operation`, `patch`, `delete`: the VFS compare-and-swap lets one commit; the
22
+ other fails its precondition.
23
+ - `event`: the event's `event_id` is derived from `(workspace_id,
24
+ idempotency_key)` (`idempotentEventId`, a UUIDv5), so the event store rejects
25
+ the second append.
26
+
27
+ The loser then re-reads the idempotency store: if the winner has already
28
+ recorded its result, the loser returns it as a replay (or an idempotency
29
+ conflict when its request differs); otherwise it fails and a retry replays. If a
30
+ process dies after appending an event but before recording the result, retries
31
+ of that key fail rather than append a second event.
32
+
33
+ ## Crashed holders
34
+
35
+ A lock records its holder's pid and host. A waiter breaks the lock when the
36
+ holder is on the same host and no longer running, or when the lock is older than
37
+ `staleMs` (default 30 s). A waiter gives up after `timeoutMs` (default 40 s) with
38
+ `SSSS_LOCK_TIMEOUT`. Both are adjustable per adapter:
39
+
40
+ ```js
41
+ new JsonlEventStore(root, { lock: { timeoutMs: 10_000, staleMs: 15_000 } });
42
+ new FileSystemVfs(root, { lock: { timeoutMs: 10_000 } });
43
+ ```
44
+
45
+ Symlinked lock files are refused.
46
+
47
+ ## Limits
48
+
49
+ - Locks are advisory: only writers using these adapters take them. Writers from
50
+ `@gregiteen/ssss-cli` 0.9.1 and earlier do not.
51
+ - Network filesystems must support exclusive create (`O_EXCL`).
52
+ - Dead-holder detection assumes processes that share the filesystem have
53
+ distinct hostnames (containers with the same hostname and separate pid
54
+ namespaces would look dead to each other). Stale detection compares the lock's
55
+ mtime with the local clock, so hosts' clocks must agree to well within
56
+ `staleMs`.
57
+
58
+ See also: `ssss help leases`, `ssss help conformance`.
@@ -33,7 +33,8 @@ vault ──export──▶ .ucw bundle ──provision──▶ envelope plan
33
33
 
34
34
  Run `ssss help <topic>` for any of:
35
35
  `runtime`, `semantic`, `portability`, `bundle`, `provisioning`,
36
- `export`, `provision`, `import`, `conformance`, `autolink`.
36
+ `export`, `provision`, `import`, `conformance`, `autolink`, `leases`,
37
+ `concurrency`.
37
38
 
38
39
  Run `ssss <command> --help` for: `export`, `validate`, `inspect`, `provision`,
39
40
  `import`, `semantic`, `primitive`, `registry`, `adapter`, `migrate`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
2
+ import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { definePrimitive, definitionsToExtensionRegistry } from '../src/primitive.mjs';
@@ -13,7 +13,7 @@ import {
13
13
  } from '../src/registry.mjs';
14
14
  import { createValidator } from '../src/validator.mjs';
15
15
  import { FileSystemVfs, MemoryVfs, runVfsContract } from '../src/vfs.mjs';
16
- import { JsonlEventStore, MemoryEventStore, createCanonicalEvent } from '../src/events.mjs';
16
+ import { JsonlEventStore, MemoryEventStore, createCanonicalEvent, idempotentEventId } from '../src/events.mjs';
17
17
  import {
18
18
  ProjectionCoordinator,
19
19
  createSqlProjectionAdapter,
@@ -25,7 +25,7 @@ import { createKernel } from '../src/kernel.mjs';
25
25
  import { UiRegistry, actionToEnvelope, createDeterministicUi, planUi, validateUiManifest } from '../src/ui.mjs';
26
26
  import { createCommandHandler, createDomainCommand } from '../src/http.mjs';
27
27
  import { detectDirectWrites } from '../src/guard.mjs';
28
- import { MemoryLeaseStore, FileLeaseStore, runLeaseContract } from '../src/leases.mjs';
28
+ import { MemoryLeaseStore, FileLeaseStore, leaseStatePath, runLeaseContract } from '../src/leases.mjs';
29
29
  import { MemoryIdempotencyStore, FileIdempotencyStore, runIdempotencyContract } from '../src/idempotency.mjs';
30
30
  import { createCapabilityAuthorizer } from '../src/authorization.mjs';
31
31
  import { validateVerifiedPrincipal } from '../src/authorization.mjs';
@@ -40,25 +40,160 @@ async function replayed(store, options) {
40
40
  return items;
41
41
  }
42
42
 
43
- // Counts bytes JsonlEventStore reads from disk, to prove appends do not re-read the log.
44
- async function bytesReadDuring(fn) {
45
- const { readSync, readFileSync } = fs;
43
+ // Counts bytes read from `file` during `fn`, to prove appends do not re-read the log.
44
+ async function bytesReadFrom(file, fn) {
45
+ const original = { openSync: fs.openSync, closeSync: fs.closeSync, readSync: fs.readSync, readFileSync: fs.readFileSync };
46
+ const fds = new Set();
46
47
  let bytes = 0;
47
- fs.readSync = (...args) => { const read = readSync(...args); bytes += read; return read; };
48
- fs.readFileSync = (...args) => { const out = readFileSync(...args); bytes += Buffer.byteLength(out); return out; };
49
- try { await fn(); } finally { fs.readSync = readSync; fs.readFileSync = readFileSync; }
48
+ fs.openSync = (target, ...rest) => { const fd = original.openSync(target, ...rest); if (target === file) fds.add(fd); return fd; };
49
+ fs.closeSync = (fd) => { fds.delete(fd); return original.closeSync(fd); };
50
+ fs.readSync = (fd, ...rest) => { const read = original.readSync(fd, ...rest); if (fds.has(fd)) bytes += read; return read; };
51
+ fs.readFileSync = (target, ...rest) => {
52
+ const out = original.readFileSync(target, ...rest);
53
+ if (target === file || fds.has(target)) bytes += Buffer.byteLength(out);
54
+ return out;
55
+ };
56
+ try { await fn(); } finally { Object.assign(fs, original); }
50
57
  return bytes;
51
58
  }
52
59
 
60
+ // Runs `body` in `count` child processes released on one shared deadline, so
61
+ // their check-then-write sections overlap. Each resolves to { ok, racer, error }.
62
+ function raceProcesses(count, imports, body) {
63
+ const startAt = Date.now() + 1000;
64
+ const code = [
65
+ ...Object.entries(imports).map(([name, file]) => `import { ${name} } from ${JSON.stringify(new URL(file, import.meta.url).href)};`),
66
+ 'const racer = Number(process.env.SSSS_RACER);',
67
+ `while (Date.now() < ${startAt});`,
68
+ `try { ${body}; console.log(JSON.stringify({ ok: true, racer })); }`,
69
+ 'catch (error) { console.log(JSON.stringify({ ok: false, racer, error: error.message })); }',
70
+ ].join('\n');
71
+ return Promise.all(Array.from({ length: count }, (_, racer) => new Promise((resolve) => {
72
+ const child = spawn(process.execPath, ['--input-type=module', '-e', code], { env: { ...process.env, SSSS_RACER: String(racer) } });
73
+ let out = '';
74
+ let err = '';
75
+ child.stdout.on('data', (chunk) => { out += chunk; });
76
+ child.stderr.on('data', (chunk) => { err += chunk; });
77
+ child.on('close', () => {
78
+ try { resolve(JSON.parse(out.trim().split('\n').pop())); }
79
+ catch { resolve({ ok: false, racer, error: `no result: ${err || out}` }); }
80
+ });
81
+ })));
82
+ }
83
+
84
+ const winners = (results) => results.filter((result) => result.ok);
85
+ const describe = (results) => JSON.stringify(results.map(({ ok, racer, error }) => (ok ? racer : `${racer}:${error}`)));
86
+
87
+ async function checkFileAdapterRaces(root, principal, check) {
88
+ const racers = 6;
89
+ const eventsRoot = path.join(root, 'events');
90
+ const event = createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/race.md', operation_id: 'race', idempotency_key: 'race', principal });
91
+ const appends = await raceProcesses(racers, { JsonlEventStore: '../src/events.mjs' },
92
+ `await new JsonlEventStore(${JSON.stringify(eventsRoot)}).append(${JSON.stringify(event)})`);
93
+ const lines = fs.readFileSync(path.join(eventsRoot, 'acme.jsonl'), 'utf8').split('\n').filter(Boolean);
94
+ check('jsonl event store lets one of several processes append the same event_id', winners(appends).length === 1 && lines.length === 1, describe(appends));
95
+
96
+ const vfsRoot = path.join(root, 'vfs');
97
+ const creates = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
98
+ `await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/create.md', 'racer-' + racer, { ifAbsent: true })`);
99
+ const created = fs.readFileSync(path.join(vfsRoot, 'race', 'create.md'), 'utf8');
100
+ check('filesystem VFS lets one of several processes create the same path',
101
+ winners(creates).length === 1 && created === `racer-${winners(creates)[0]?.racer}`, describe(creates));
102
+ const base = await new FileSystemVfs(vfsRoot).writeAtomic('race/cas.md', 'base', { ifAbsent: true });
103
+ const swaps = await raceProcesses(racers, { FileSystemVfs: '../src/vfs.mjs' },
104
+ `await new FileSystemVfs(${JSON.stringify(vfsRoot)}).writeAtomic('race/cas.md', 'racer-' + racer, { version: ${JSON.stringify(base.version)} })`);
105
+ const swapped = fs.readFileSync(path.join(vfsRoot, 'race', 'cas.md'), 'utf8');
106
+ check('filesystem VFS lets one of several processes compare-and-swap the same version',
107
+ winners(swaps).length === 1 && swapped === `racer-${winners(swaps)[0]?.racer}`, describe(swaps));
108
+
109
+ const leaseRoot = path.join(root, 'leases');
110
+ const leases = await raceProcesses(racers, { FileLeaseStore: '../src/leases.mjs' },
111
+ `await new FileLeaseStore(${JSON.stringify(leaseRoot)}).acquire({ workspace_id: 'acme', target: 'race.md', principal_id: 'p' + racer, operation_id: 'o' + racer }, 60000)`);
112
+ const held = JSON.parse(fs.readFileSync(leaseStatePath(fs.realpathSync(leaseRoot), 'acme', 'race.md'), 'utf8'));
113
+ check('filesystem lease store grants one of several processes the same lease',
114
+ winners(leases).length === 1 && held.principal_id === `p${winners(leases)[0]?.racer}`, describe(leases));
115
+
116
+ const idempotencyRoot = path.join(root, 'idempotency');
117
+ const puts = await raceProcesses(racers, { FileIdempotencyStore: '../src/idempotency.mjs' },
118
+ `await new FileIdempotencyStore(${JSON.stringify(idempotencyRoot)}).put('acme', 'race', { request_hash: 'racer-' + racer })`);
119
+ const stored = await new FileIdempotencyStore(idempotencyRoot).get('acme', 'race');
120
+ check('filesystem idempotency store accepts one of several processes putting the same key',
121
+ winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
122
+
123
+ const kernelRoot = path.join(root, 'kernel');
124
+ const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
125
+ const stores = () => ({
126
+ vfs: new FileSystemVfs(kernelRoot),
127
+ eventStore: new JsonlEventStore(path.join(kernelRoot, '.events')),
128
+ idempotencyStore: new FileIdempotencyStore(path.join(kernelRoot, '.idempotency')),
129
+ });
130
+ const envelope = (key, content) => ({ type: 'event', workspace_id: 'acme', idempotency_key: key, path: 'events/race.md', content });
131
+ const kernelRace = (key, contentExpression) => raceProcesses(racers, {
132
+ createKernel: '../src/kernel.mjs', FileSystemVfs: '../src/vfs.mjs', JsonlEventStore: '../src/events.mjs', FileIdempotencyStore: '../src/idempotency.mjs',
133
+ }, [
134
+ `const kernel = createKernel({ vfs: new FileSystemVfs(${JSON.stringify(kernelRoot)}), eventStore: new JsonlEventStore(${JSON.stringify(path.join(kernelRoot, '.events'))}), idempotencyStore: new FileIdempotencyStore(${JSON.stringify(path.join(kernelRoot, '.idempotency'))}) })`,
135
+ `const result = await kernel.execute({ type: 'event', workspace_id: 'acme', idempotency_key: ${JSON.stringify(key)}, path: 'events/race.md', content: ${contentExpression} }, { principal: ${JSON.stringify(kernelPrincipal)} })`,
136
+ "if (!result.success) throw new Error(result.validation.errors.join('; '))",
137
+ ].join(';\n'));
138
+ const eventsFor = (key) => fs.readFileSync(path.join(kernelRoot, '.events', 'acme.jsonl'), 'utf8')
139
+ .split('\n').filter(Boolean).map((line) => JSON.parse(line)).filter((event) => event.idempotency_key === key);
140
+ const same = await kernelRace('race-same', 'JSON.stringify({ n: 1 })');
141
+ const retried = await createKernel(stores()).execute(envelope('race-same', JSON.stringify({ n: 1 })), { principal: kernelPrincipal });
142
+ check('kernel appends one event when several processes send the same event envelope',
143
+ eventsFor('race-same').length === 1 && winners(same).length >= 1 && retried.replay === true, describe(same));
144
+ const differing = await kernelRace('race-differ', 'JSON.stringify({ racer })');
145
+ check('kernel appends one event when several processes reuse a key with different content',
146
+ eventsFor('race-differ').length === 1 && winners(differing).length === 1, describe(differing));
147
+ check('kernel derives event envelope ids from the idempotency key',
148
+ eventsFor('race-same')[0]?.event_id === idempotentEventId('acme', 'race-same'));
149
+ }
150
+
151
+ async function checkFileLocks(root, principal, check) {
152
+ const make = (id) => createCanonicalEvent({ workspace_id: 'acme', action: 'event', subject: 'events/lock.md', operation_id: id, idempotency_key: id, principal });
153
+ const lock = path.join(root, 'acme.jsonl.lock');
154
+ const holder = (fields) => fs.writeFileSync(lock, JSON.stringify({ token: 'other', host: os.hostname(), ...fields }));
155
+ const store = new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 60_000 } });
156
+
157
+ holder({ pid: process.pid });
158
+ const started = Date.now();
159
+ check('file locks make a writer wait for a live holder and then time out',
160
+ await rejects(store.append(make('lock-live')), /Timed out waiting for lock/) && Date.now() - started >= 450 && fs.existsSync(lock));
161
+ const exited = spawnSync(process.execPath, ['-e', '']).pid;
162
+ holder({ pid: exited });
163
+ await store.append(make('lock-dead'));
164
+ check('file locks break a lock whose holder process has exited', !fs.existsSync(lock));
165
+ holder({ pid: process.pid, host: 'another-host' });
166
+ const past = new Date(Date.now() - 120_000);
167
+ fs.utimesSync(lock, past, past);
168
+ await new JsonlEventStore(root, { lock: { timeoutMs: 500, staleMs: 30_000 } }).append(make('lock-stale'));
169
+ check('file locks break a lock older than the stale threshold', !fs.existsSync(lock));
170
+ const decoy = path.join(root, 'decoy.txt');
171
+ fs.writeFileSync(decoy, 'untouched');
172
+ fs.symlinkSync(decoy, lock);
173
+ check('file locks refuse a symlinked lock file',
174
+ await rejects(store.append(make('lock-symlink')), /not a regular file/) && fs.readFileSync(decoy, 'utf8') === 'untouched');
175
+ fs.rmSync(lock);
176
+
177
+ const vfs = new FileSystemVfs(path.join(root, 'vfs'));
178
+ await vfs.writeAtomic('docs/one.md', 'one', { ifAbsent: true });
179
+ fs.writeFileSync(path.join(vfs.root, '.ssss-locks', 'leftover.lock'), '{}');
180
+ const listed = [];
181
+ for await (const entry of vfs.list()) listed.push(entry.path);
182
+ check('filesystem VFS reserves its lock directory',
183
+ await rejects(vfs.writeAtomic('.ssss-locks/forged.lock', 'x'), /reserved/)
184
+ && await rejects(vfs.writeAtomic('.SSSS-Locks/forged.lock', 'x'), /reserved/)
185
+ && JSON.stringify(listed) === JSON.stringify(['docs/one.md']));
186
+ }
187
+
53
188
  async function checkJsonlEventStoreIndex(root, principal, check) {
54
189
  let seq = 0;
55
190
  const make = (workspace = 'acme') => createCanonicalEvent({
56
191
  workspace_id: workspace, action: 'event', subject: 'events/indexed.md',
57
192
  operation_id: `indexed-${++seq}`, idempotency_key: `indexed-${seq}`, principal,
58
193
  });
59
- const log = path.join(root, 'acme.jsonl');
60
194
  const first = new JsonlEventStore(root);
61
195
  const second = new JsonlEventStore(root);
196
+ const log = path.join(first.root, 'acme.jsonl');
62
197
  const appended = [];
63
198
  const add = async (store, event) => { await store.append(event); appended.push(event.event_id); return event; };
64
199
 
@@ -78,10 +213,10 @@ async function checkJsonlEventStoreIndex(root, principal, check) {
78
213
 
79
214
  const seeded = fs.statSync(log).size;
80
215
  const fresh = new JsonlEventStore(root);
81
- const batchBytes = await bytesReadDuring(async () => { for (let i = 0; i < 200; i++) await add(fresh, make()); });
216
+ const batchBytes = await bytesReadFrom(log, async () => { for (let i = 0; i < 200; i++) await add(fresh, make()); });
82
217
  check('jsonl event store reads the log once for many appends from one instance', batchBytes === seeded, `read ${batchBytes} bytes; log was ${seeded}`);
83
218
  await add(second, make());
84
- const revalidateBytes = await bytesReadDuring(() => add(fresh, make()));
219
+ const revalidateBytes = await bytesReadFrom(log, () => add(fresh, make()));
85
220
  check('jsonl event store rebuilds its index after another writer appends', revalidateBytes > seeded);
86
221
 
87
222
  const other = [];
@@ -277,6 +412,8 @@ export async function runKernel09Conformance() {
277
412
  check(`${name} event store is append-only and replayable`, duplicate && replayed === 1);
278
413
  }
279
414
  await checkJsonlEventStoreIndex(path.join(temp, 'events-index'), principal, check);
415
+ await checkFileAdapterRaces(path.join(temp, 'races'), principal, check);
416
+ await checkFileLocks(path.join(temp, 'locks'), principal, check);
280
417
  const denied = await kernel.execute({ type: 'patch', workspace_id: 'acme', idempotency_key: 'denied', path: 'bookings/one.md', patches: { status: 'pending' } }, {
281
418
  principal: { ...principal, id: 'outsider', workspaceIds: [], capabilities: ['*:*'] },
282
419
  });
@@ -2,7 +2,9 @@
2
2
  # ============================================================
3
3
  # SSSS — Version Release Script
4
4
  #
5
- # Bumps the VERSION file and adds a stub in CHANGELOG.md
5
+ # Bumps VERSION, package.json and package-lock.json, and turns the
6
+ # CHANGELOG's [Unreleased] entries into a section for the new version
7
+ # (a stub when [Unreleased] is empty).
6
8
  # ============================================================
7
9
 
8
10
  set -euo pipefail
@@ -20,16 +22,21 @@ if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
20
22
  exit 1
21
23
  fi
22
24
 
23
- echo "$NEW_VERSION" > VERSION
24
-
25
- # Quick sed to insert new version at the top of the changelog
26
- sed -i.bak "/^## \[.*\]/i \\
27
- ## [$NEW_VERSION] - $(date +%Y-%m-%d)\\
28
- ### Added\\
29
- - \\
30
- \\
31
- " CHANGELOG.md
25
+ cd "$(dirname "$0")/.."
32
26
 
33
- rm -f CHANGELOG.md.bak
34
-
35
- echo "✅ Bumped version to $NEW_VERSION"
27
+ echo "$NEW_VERSION" > VERSION
28
+ npm version "$NEW_VERSION" --no-git-tag-version --allow-same-version > /dev/null
29
+
30
+ node --input-type=module -e '
31
+ import fs from "node:fs";
32
+ const [version, date] = process.argv.slice(1);
33
+ const text = fs.readFileSync("CHANGELOG.md", "utf8");
34
+ if (text.includes(`## [${version}]`)) process.exit(0);
35
+ const match = text.match(/^## \[Unreleased\]\n([\s\S]*?)(?=^## \[)/m);
36
+ if (!match) { console.error("CHANGELOG.md has no ## [Unreleased] section followed by a release."); process.exit(1); }
37
+ const entries = match[1].trim() || "### Added\n- ";
38
+ const section = `## [Unreleased]\n\n## [${version}] - ${date}\n${entries}\n\n`;
39
+ fs.writeFileSync("CHANGELOG.md", text.replace(match[0], section));
40
+ ' "$NEW_VERSION" "$(date +%Y-%m-%d)"
41
+
42
+ echo "✅ Bumped version to $NEW_VERSION (VERSION, package.json, package-lock.json, CHANGELOG.md)"
package/src/events.mjs CHANGED
@@ -2,6 +2,23 @@
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';
6
+
7
+ // UUIDv5 namespace for event ids derived from idempotency keys.
8
+ const IDEMPOTENT_EVENT_NAMESPACE = Buffer.from('6f1c2d9e4b7a4e0c9f35a1d8c3b2e7f4', 'hex');
9
+
10
+ /**
11
+ * Deterministic event_id (UUIDv5) for one (workspace_id, idempotency_key).
12
+ * The kernel uses it for `event` envelopes so a store's duplicate-event_id
13
+ * check stops two concurrent requests with the same key from both appending.
14
+ */
15
+ export function idempotentEventId(workspaceId, idempotencyKey) {
16
+ const hash = crypto.createHash('sha1').update(IDEMPOTENT_EVENT_NAMESPACE).update(JSON.stringify([workspaceId, idempotencyKey])).digest();
17
+ hash[6] = (hash[6] & 0x0f) | 0x50;
18
+ hash[8] = (hash[8] & 0x3f) | 0x80;
19
+ const hex = hash.subarray(0, 16).toString('hex');
20
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
21
+ }
5
22
 
6
23
  export function createCanonicalEvent(input, options = {}) {
7
24
  const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
@@ -90,10 +107,13 @@ export class JsonlEventStore {
90
107
  // Per-log index of event_ids, built on the first append to that log. Each
91
108
  // index records the stat of the bytes it covers; any change (another store
92
109
  // instance or process appended, or the file was replaced) rebuilds it.
110
+ // Appenders serialize on `<log>.lock` so the duplicate check and the append
111
+ // are atomic across processes.
93
112
  #indexes = new Map();
94
- constructor(root) {
113
+ constructor(root, options = {}) {
95
114
  fs.mkdirSync(root, { recursive: true });
96
115
  this.root = fs.realpathSync(root);
116
+ this.lockOptions = options.lock || {};
97
117
  }
98
118
  #file(workspaceId) {
99
119
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
@@ -116,6 +136,9 @@ export class JsonlEventStore {
116
136
  }
117
137
  async append(event) {
118
138
  const file = this.#file(event.workspace_id);
139
+ return withFileLock(`${file}.lock`, () => this.#appendLocked(file, event), this.lockOptions);
140
+ }
141
+ #appendLocked(file, event) {
119
142
  const index = this.#index(file);
120
143
  if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
121
144
  const line = `${JSON.stringify(event)}\n`;
@@ -127,7 +150,8 @@ export class JsonlEventStore {
127
150
  after = fs.fstatSync(fd);
128
151
  } finally { fs.closeSync(fd); }
129
152
  // Keep the index only when the log grew by exactly this line on the same
130
- // file; otherwise another writer interleaved and the next append rebuilds.
153
+ // file; otherwise a writer that skips the lock interleaved, and the next
154
+ // append rebuilds.
131
155
  const before = index.stat;
132
156
  const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
133
157
  if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
@@ -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/kernel.mjs CHANGED
@@ -7,7 +7,7 @@ import { parseDocument, serializeDocument } from './frontmatter.mjs';
7
7
  import { createValidator } from './validator.mjs';
8
8
  import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
9
9
  import { createCapabilityAuthorizer } from './authorization.mjs';
10
- import { createCanonicalEvent } from './events.mjs';
10
+ import { createCanonicalEvent, idempotentEventId } from './events.mjs';
11
11
  import { MemoryIdempotencyStore } from './idempotency.mjs';
12
12
 
13
13
  export { MemoryIdempotencyStore } from './idempotency.mjs';
@@ -92,13 +92,17 @@ export function createKernel(options = {}) {
92
92
  }
93
93
 
94
94
  const requestHash = canonicalRequestHash(envelope, principal);
95
- const replay = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
96
- if (replay) {
97
- if (replay.request_hash !== requestHash) {
95
+ // A stored result for this key: replay it, or refuse a different request.
96
+ const settled = async () => {
97
+ const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
98
+ if (!stored) return null;
99
+ if (stored.request_hash !== requestHash) {
98
100
  return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
99
101
  }
100
- return { ...replay.response, replay: true };
101
- }
102
+ return { ...stored.response, replay: true };
103
+ };
104
+ const replay = await settled();
105
+ if (replay) return replay;
102
106
 
103
107
  let current;
104
108
  try { current = await vfs.read(envelope.path); }
@@ -211,6 +215,9 @@ export function createKernel(options = {}) {
211
215
  }
212
216
 
213
217
  const event = createCanonicalEvent({
218
+ // `event` envelopes have no VFS compare-and-swap, so a key-derived id
219
+ // lets the event store reject a concurrent request with the same key.
220
+ event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
214
221
  workspace_id: envelope.workspace_id,
215
222
  primitive_id: definition.qualified_type,
216
223
  primitive_version: definition.primitive_version || 1,
@@ -252,6 +259,12 @@ export function createKernel(options = {}) {
252
259
  return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
253
260
  }
254
261
  }
262
+ // A concurrent request with the same key may have won the commit (VFS
263
+ // compare-and-swap or duplicate event_id); answer with its result.
264
+ try {
265
+ const winner = await settled();
266
+ if (winner) return winner;
267
+ } catch {}
255
268
  return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
256
269
  }
257
270
  }
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']) {
@@ -56,10 +57,16 @@ export class FileLeaseStore {
56
57
  fs.mkdirSync(root, { recursive: true });
57
58
  this.root = fs.realpathSync(root);
58
59
  this.clock = options.clock || (() => Date.now());
60
+ this.lockOptions = options.lock || {};
59
61
  }
60
62
  #file(workspaceId, target) {
61
63
  return leaseStatePath(this.root, workspaceId, target);
62
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);
69
+ }
63
70
  #load(workspaceId, target) {
64
71
  const file = this.#file(workspaceId, target);
65
72
  if (!fs.existsSync(file)) return null;
@@ -78,11 +85,13 @@ export class FileLeaseStore {
78
85
  }
79
86
  async acquire(input, ttlMs = 30_000) {
80
87
  validateLeaseInput(input);
81
- const existing = this.#load(input.workspace_id, input.target);
82
- if (existing && existing.expires_at > this.clock()) throw new Error('Lease conflict.');
83
- const lease = { ...input, lease_id: input.lease_id || crypto.randomUUID(), issued_at: this.clock(), expires_at: this.clock() + ttlMs };
84
- this.#write(lease);
85
- 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
+ });
86
95
  }
87
96
  async verify(input) {
88
97
  const lease = this.#load(input.workspace_id, input.target);
@@ -94,17 +103,21 @@ export class FileLeaseStore {
94
103
  return { valid: true, lease };
95
104
  }
96
105
  async renew(input, ttlMs = 30_000) {
97
- const verified = await this.verify(input);
98
- if (!verified.valid) throw new Error(verified.reason);
99
- verified.lease.expires_at = this.clock() + ttlMs;
100
- this.#write(verified.lease);
101
- 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
+ });
102
113
  }
103
114
  async release(input) {
104
- const verified = await this.verify(input);
105
- if (!verified.valid) throw new Error(verified.reason);
106
- fs.rmSync(this.#file(input.workspace_id, input.target), { force: true });
107
- 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
+ });
108
121
  }
109
122
  }
110
123
 
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