@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/CHANGELOG.md +83 -4
- package/README.md +41 -82
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +19 -1
- package/docs/help/concurrency.md +45 -0
- package/docs/help/overview.md +8 -4
- package/docs/ssss-spec.md +11 -10
- package/package.json +1 -1
- package/registry/core.json +1 -1
- package/scripts/conformance-09.mjs +250 -8
- package/scripts/conformance.mjs +47 -0
- package/scripts/generate-sbom.mjs +96 -0
- package/scripts/phase9-cross-host-corpus.mjs +223 -0
- package/scripts/release.sh +20 -13
- package/skills/ssss/SKILL.md +32 -204
- package/src/authorization.mjs +2 -1
- package/src/bundle.mjs +37 -1
- package/src/events.mjs +83 -10
- package/src/file-lock.mjs +112 -0
- package/src/idempotency.mjs +15 -7
- package/src/leases.mjs +77 -16
- package/src/projections.mjs +72 -1
- package/src/vfs.mjs +33 -7
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.#
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
package/src/idempotency.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 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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
}
|
package/src/projections.mjs
CHANGED
|
@@ -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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|