@gregiteen/ssss-cli 0.9.0 → 0.9.1

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/bundle.mjs CHANGED
@@ -197,6 +197,17 @@ export function exportBundle(vaultRoot, opts = {}) {
197
197
  }
198
198
 
199
199
  const files = canonicalFiles(kept);
200
+ const hash = contentHash(files);
201
+ const primitiveDeps = Object.keys(inventory).sort();
202
+ const migrationDeps = files
203
+ .map((file) => {
204
+ try {
205
+ const { data } = parseDocument(file.content);
206
+ return data.type === 'migration' ? (data.migration_id || data.slug || file.path) : null;
207
+ } catch { return null; }
208
+ })
209
+ .filter(Boolean)
210
+ .sort();
200
211
  const manifest = {
201
212
  name, description, version,
202
213
  exported_at: exportedAt,
@@ -204,10 +215,16 @@ export function exportBundle(vaultRoot, opts = {}) {
204
215
  required_extensions: requiredExtensions,
205
216
  export_profile: profile,
206
217
  primitive_inventory: inventory,
218
+ dependencies: {
219
+ primitives: primitiveDeps,
220
+ extensions: [...requiredExtensions].sort(),
221
+ migrations: migrationDeps,
222
+ integrity: { content_hash: hash, ssss_core_version: core.spec_version },
223
+ },
207
224
  provisioning: manifestProvisioning,
208
225
  parameters: manifestParameters,
209
226
  file_count: files.length,
210
- provenance: { content_hash: contentHash(files), exporter },
227
+ provenance: { content_hash: hash, exporter },
211
228
  };
212
229
  // source_workspace_id is operator-identifying — only carried in a backup (§16.2).
213
230
  if (profile === 'backup' && sourceWorkspaceId) manifest.source_workspace_id = sourceWorkspaceId;
@@ -240,6 +257,25 @@ export function validateBundle(bundle, opts = {}) {
240
257
  if (!m.primitive_inventory || typeof m.primitive_inventory !== 'object' || Array.isArray(m.primitive_inventory)) {
241
258
  errors.push('manifest.primitive_inventory must be an object');
242
259
  }
260
+ if (m.dependencies !== undefined) {
261
+ if (!m.dependencies || typeof m.dependencies !== 'object' || Array.isArray(m.dependencies)) {
262
+ errors.push('manifest.dependencies must be an object');
263
+ } else {
264
+ for (const key of ['primitives', 'extensions', 'migrations']) {
265
+ if (!Array.isArray(m.dependencies[key])) errors.push(`manifest.dependencies.${key} must be an array`);
266
+ }
267
+ if (!m.dependencies.integrity || typeof m.dependencies.integrity !== 'object' || Array.isArray(m.dependencies.integrity)) {
268
+ errors.push('manifest.dependencies.integrity must be an object');
269
+ } else if (typeof m.dependencies.integrity.content_hash !== 'string' || !m.dependencies.integrity.content_hash.startsWith('sha256:')) {
270
+ errors.push('manifest.dependencies.integrity.content_hash must be a sha256 digest');
271
+ } else if (
272
+ m.provenance?.content_hash
273
+ && m.dependencies.integrity.content_hash !== m.provenance.content_hash
274
+ ) {
275
+ errors.push('manifest.dependencies.integrity.content_hash must match provenance.content_hash');
276
+ }
277
+ }
278
+ }
243
279
  for (const ext of requiredExtensions) {
244
280
  if (typeof ext !== 'string' || !ext) {
245
281
  errors.push('required extension ids must be non-empty strings');
package/src/events.mjs CHANGED
@@ -33,12 +33,12 @@ export function createCanonicalEvent(input, options = {}) {
33
33
 
34
34
  export class MemoryEventStore {
35
35
  #events = [];
36
+ #ids = new Set();
36
37
  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
- }
38
+ if (this.#ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
40
39
  const frozen = structuredClone(event);
41
40
  this.#events.push(frozen);
41
+ this.#ids.add(frozen.event_id);
42
42
  return structuredClone(frozen);
43
43
  }
44
44
  async *replay(options = {}) {
@@ -51,7 +51,46 @@ export class MemoryEventStore {
51
51
  async size() { return this.#events.length; }
52
52
  }
53
53
 
54
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
55
+ const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | NOFOLLOW;
56
+
57
+ function openLog(file, flags, mode) {
58
+ try { return fs.openSync(file, flags, mode); }
59
+ catch (error) {
60
+ if (error.code === 'ELOOP') throw new Error('Symlinked event logs are forbidden.');
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ function sameStat(a, b) {
66
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
67
+ }
68
+
69
+ /** Read every event_id in a log, bounded to the bytes covered by the returned stat. */
70
+ function loadIndex(file) {
71
+ const fd = openLog(file, fs.constants.O_RDONLY | NOFOLLOW);
72
+ try {
73
+ const stat = fs.fstatSync(fd);
74
+ const bytes = Buffer.alloc(stat.size);
75
+ let offset = 0;
76
+ while (offset < bytes.length) {
77
+ const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
78
+ if (read === 0) break;
79
+ offset += read;
80
+ }
81
+ const ids = new Set();
82
+ for (const line of bytes.subarray(0, offset).toString('utf8').split('\n')) {
83
+ if (line.trim()) ids.add(JSON.parse(line).event_id);
84
+ }
85
+ return { ids, stat };
86
+ } finally { fs.closeSync(fd); }
87
+ }
88
+
54
89
  export class JsonlEventStore {
90
+ // Per-log index of event_ids, built on the first append to that log. Each
91
+ // index records the stat of the bytes it covers; any change (another store
92
+ // instance or process appended, or the file was replaced) rebuilds it.
93
+ #indexes = new Map();
55
94
  constructor(root) {
56
95
  fs.mkdirSync(root, { recursive: true });
57
96
  this.root = fs.realpathSync(root);
@@ -60,15 +99,41 @@ export class JsonlEventStore {
60
99
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
61
100
  return path.join(this.root, `${workspaceId}.jsonl`);
62
101
  }
102
+ #index(file) {
103
+ let stat;
104
+ try { stat = fs.lstatSync(file); }
105
+ catch (error) {
106
+ if (error.code !== 'ENOENT') throw error;
107
+ this.#indexes.delete(file);
108
+ return { ids: new Set(), stat: null };
109
+ }
110
+ if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
111
+ const cached = this.#indexes.get(file);
112
+ if (cached && sameStat(cached.stat, stat)) return cached;
113
+ const index = loadIndex(file);
114
+ this.#indexes.set(file, index);
115
+ return index;
116
+ }
63
117
  async append(event) {
64
118
  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
- }
119
+ const index = this.#index(file);
120
+ if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
121
+ const line = `${JSON.stringify(event)}\n`;
122
+ this.#indexes.delete(file);
123
+ const fd = openLog(file, APPEND_FLAGS, 0o600);
124
+ let after;
125
+ try {
126
+ fs.writeFileSync(fd, line);
127
+ after = fs.fstatSync(fd);
128
+ } finally { fs.closeSync(fd); }
129
+ // 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.
131
+ const before = index.stat;
132
+ const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
133
+ if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
134
+ index.ids.add(event.event_id);
135
+ this.#indexes.set(file, { ids: index.ids, stat: after });
70
136
  }
71
- fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
72
137
  return structuredClone(event);
73
138
  }
74
139
  async *replay(options = {}) {
package/src/leases.mjs CHANGED
@@ -46,6 +46,11 @@ export class MemoryLeaseStore {
46
46
  }
47
47
  }
48
48
 
49
+ export function leaseStatePath(root, workspaceId, target) {
50
+ const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
51
+ return path.join(root, `${hash}.lease.json`);
52
+ }
53
+
49
54
  export class FileLeaseStore {
50
55
  constructor(root, options = {}) {
51
56
  fs.mkdirSync(root, { recursive: true });
@@ -53,8 +58,7 @@ export class FileLeaseStore {
53
58
  this.clock = options.clock || (() => Date.now());
54
59
  }
55
60
  #file(workspaceId, target) {
56
- const hash = crypto.createHash('sha256').update(`${workspaceId}\0${target}`).digest('hex');
57
- return path.join(this.root, `${hash}.lease.json`);
61
+ return leaseStatePath(this.root, workspaceId, target);
58
62
  }
59
63
  #load(workspaceId, target) {
60
64
  const file = this.#file(workspaceId, target);
@@ -117,9 +121,53 @@ export async function runLeaseContract(store, { clock } = {}) {
117
121
  record('acquire returns bound lease', acquired.lease_id && acquired.principal_id === input.principal_id);
118
122
  record('matching lease verifies', (await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
119
123
  record('mismatched owner fails closed', !(await store.verify({ ...input, principal_id: 'other', lease_id: acquired.lease_id })).valid);
124
+ record('mismatched lease_id fails closed', !(await store.verify({ ...input, lease_id: crypto.randomUUID() })).valid);
125
+ record('missing lease fails closed', !(await store.verify({
126
+ workspace_id: input.workspace_id,
127
+ target: `missing/${crypto.randomUUID()}.md`,
128
+ principal_id: input.principal_id,
129
+ operation_id: input.operation_id,
130
+ lease_id: acquired.lease_id,
131
+ })).valid);
120
132
  const renewed = await store.renew({ ...input, lease_id: acquired.lease_id }, 20_000);
121
133
  record('renew extends expiry', renewed.expires_at >= acquired.expires_at);
122
134
  await store.release({ ...input, lease_id: acquired.lease_id });
123
135
  record('released lease is missing', !(await store.verify({ ...input, lease_id: acquired.lease_id })).valid);
136
+
137
+ // Controllable clock proves expiry fail-closed for any store that accepts injection.
138
+ if (typeof store.clock === 'function' || clock) {
139
+ let now = 1_000_000;
140
+ const timed = store.constructor === MemoryLeaseStore
141
+ ? new MemoryLeaseStore({ clock: () => now })
142
+ : store.constructor === FileLeaseStore
143
+ ? new FileLeaseStore(path.join(store.root, `expiry-${crypto.randomUUID()}`), { clock: () => now })
144
+ : null;
145
+ if (timed) {
146
+ const timedInput = { ...input, target: `rules/expiry-${crypto.randomUUID()}.md` };
147
+ const short = await timed.acquire(timedInput, 50);
148
+ now = short.expires_at + 1;
149
+ record('expired lease fails closed', !(await timed.verify({ ...timedInput, lease_id: short.lease_id })).valid);
150
+ }
151
+ }
152
+
153
+ // Filesystem stores must fail closed on corrupt/unreadable state.
154
+ if (store instanceof FileLeaseStore) {
155
+ const corruptInput = {
156
+ workspace_id: `ws-${crypto.randomUUID()}`,
157
+ target: `rules/corrupt-${crypto.randomUUID()}.md`,
158
+ principal_id: 'principal',
159
+ operation_id: 'operation',
160
+ };
161
+ const good = await store.acquire(corruptInput, 30_000);
162
+ fs.writeFileSync(leaseStatePath(store.root, corruptInput.workspace_id, corruptInput.target), '{not-json', { mode: 0o600 });
163
+ let unreadable = false;
164
+ try {
165
+ await store.verify({ ...corruptInput, lease_id: good.lease_id });
166
+ } catch (error) {
167
+ unreadable = /unreadable/i.test(error.message);
168
+ }
169
+ record('unreadable lease state fails closed', unreadable);
170
+ }
171
+
124
172
  return { passed: checks.every((check) => check.passed), checks };
125
173
  }
@@ -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.');