@gregiteen/ssss-cli 0.11.0 → 0.11.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.
@@ -57,6 +57,12 @@ const KNOWN_NON_ENGINE_KEYS = new Set([
57
57
  'optional_fields', 'type', 'registry',
58
58
  'portability', // consumed by src/registry.mjs resolvePortability() + src/bundle.mjs export filtering, not write-time validation
59
59
  'resource', // extension-defined resource_bound metadata, consumed by src/bundle.mjs provisioning
60
+ 'description', // human documentation on a primitive definition, same role as `notes`; never enforced at write time
61
+ // Product-side projection metadata. The SSSS engine never reads these: they are
62
+ // consumed by the host application's own projector to decide which target tables
63
+ // a document feeds and how (see festech.live SsssGenericProjector.ts). Kept in the
64
+ // extension registry so a product's projection contract travels with its types.
65
+ 'projection_strategy', 'projection_targets',
60
66
  ]);
61
67
 
62
68
  function loadAllPrimitiveDefs() {
@@ -23,6 +23,7 @@
23
23
  * [--max-per-target N] [--min-len N] [--index]
24
24
  * [--include-headings] [--concurrency N]
25
25
  */
26
+ import { parseFrontmatter } from '../src/frontmatter.mjs';
26
27
  import fs from 'node:fs';
27
28
  import path from 'node:path';
28
29
  import { fileURLToPath } from 'node:url';
@@ -85,21 +86,15 @@ function splitFrontmatter(content) {
85
86
  };
86
87
  }
87
88
 
88
- /** Pull title / name / slug / aliases[] out of a YAML frontmatter block (no dep). */
89
+ /** Pull title / name / slug / aliases[] out of a frontmatter block (fences included). */
89
90
  function readMeta(fm) {
90
- const scalar = (k) => {
91
- const m = fm.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
92
- return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
91
+ const data = parseFrontmatter(fm.replace(/^---[^\n]*\n/, '').replace(/\n---[^\n]*\n?$/, '\n'));
92
+ const text = (v) => (typeof v === 'string' && v ? v : null);
93
+ const aliases = Array.isArray(data.aliases) ? data.aliases : typeof data.aliases === 'string' ? [data.aliases] : [];
94
+ return {
95
+ title: text(data.title), name: text(data.name), slug: text(data.slug),
96
+ aliases: aliases.map((x) => String(x).trim()).filter(Boolean)
93
97
  };
94
- const meta = { title: scalar('title'), name: scalar('name'), slug: scalar('slug'), aliases: [] };
95
- // aliases: inline [a, b] or YAML block list
96
- const inline = fm.match(/^aliases:\s*\[(.*)\]\s*$/m);
97
- if (inline) meta.aliases = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
98
- else {
99
- const block = fm.match(/^aliases:\s*\n((?:\s*-\s*.+\n?)+)/m);
100
- if (block) meta.aliases = block[1].split('\n').map(l => l.replace(/^\s*-\s*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean);
101
- }
102
- return meta;
103
98
  }
104
99
 
105
100
  function buildGlossary(files, root) {
@@ -34,7 +34,52 @@ try {
34
34
  await assert.rejects(new JsonlEventStore(root).append(third), /Duplicate event_id/);
35
35
  fs.appendFileSync(file, '{invalid JSON}\n');
36
36
  await assert.rejects(new JsonlEventStore(root).append(make(4)), SyntaxError);
37
- console.log(JSON.stringify({ ok: true, boundedReads: true, utf8: true, cursor: true, duplicate: true, corruptFailClosed: true, largestRead }));
37
+
38
+ // Rotation: an append-only log must stay bounded, and rotating it must not
39
+ // reorder, drop or duplicate a single event. Live-first ordering was a real
40
+ // bug: archives hold the OLDER events, so they replay first.
41
+ const rotateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-event-rotate-'));
42
+ try {
43
+ const cap = 2048;
44
+ const store = new JsonlEventStore(rotateRoot, { maxBytes: cap });
45
+ const total = 40;
46
+ const expected = [];
47
+ for (let i = 0; i < total; i++) {
48
+ const event = createCanonicalEvent({
49
+ workspace_id: 'default', action: 'event', subject: `subject-${i}`,
50
+ principal: { id: 'test', kind: 'service' }, operation_id: `operation-${i}`,
51
+ idempotency_key: `key-${i}`, payload: { filler: 'b'.repeat(400) }
52
+ });
53
+ expected.push(event.event_id);
54
+ await store.append(event);
55
+ }
56
+ const live = path.join(rotateRoot, 'default.jsonl');
57
+ assert.ok(fs.statSync(live).size <= cap, 'Live log exceeded the rotation cap');
58
+ const segments = fs.readdirSync(rotateRoot).filter(name => /^default\.\d+\.jsonl$/.test(name));
59
+ assert.ok(segments.length > 0, 'Expected the log to rotate at least once');
60
+
61
+ const replayed = [];
62
+ for await (const entry of store.replay({ workspaceId: 'default' })) replayed.push(entry.event.event_id);
63
+ assert.deepEqual(replayed, expected, 'Rotation reordered, dropped or duplicated events');
64
+
65
+ // A cursor must resume a stable position across rotation.
66
+ const from = 12;
67
+ const resumed = [];
68
+ for await (const entry of store.replay({ workspaceId: 'default', cursor: from })) resumed.push(entry.cursor);
69
+ assert.deepEqual(resumed, Array.from({ length: total - from }, (_, i) => from + i + 1), 'Cursor resume shifted across rotation');
70
+
71
+ // Uniqueness must survive rotation: the index is rebuilt from archives.
72
+ await assert.rejects(new JsonlEventStore(rotateRoot, { maxBytes: cap }).append(
73
+ createCanonicalEvent({
74
+ event_id: expected[0], workspace_id: 'default', action: 'event', subject: 'dupe',
75
+ principal: { id: 'test', kind: 'service' }, operation_id: 'operation-dupe',
76
+ idempotency_key: 'key-dupe', payload: {}
77
+ })
78
+ ), /Duplicate event_id/, 'Rotation lost duplicate detection');
79
+ console.log(JSON.stringify({ ok: true, boundedReads: true, utf8: true, cursor: true, duplicate: true, corruptFailClosed: true, largestRead, rotation: { segments: segments.length, ordered: true, capped: true } }));
80
+ } finally {
81
+ fs.rmSync(rotateRoot, { recursive: true });
82
+ }
38
83
  } finally {
39
84
  Buffer.alloc = originals.alloc; fs.readFileSync = originals.readFileSync; fs.readSync = originals.readSync;
40
85
  fs.rmSync(root, { recursive: true });
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // Regression checks for src/frontmatter.mjs: the dependency-free parser must read
3
+ // the YAML that real tools (Total Recall via js-yaml, editors, agents) write.
4
+ import assert from 'node:assert/strict';
5
+ import { parseFrontmatter, parseDocument, serializeDocument } from '../src/frontmatter.mjs';
6
+
7
+ const parse = (...lines) => parseFrontmatter(lines.join('\n') + '\n');
8
+ let passed = 0;
9
+ const check = (name, fn) => { fn(); passed++; console.log(` ✅ ${name}`); };
10
+
11
+ check('hyphenated and dotted keys are kept', () => {
12
+ assert.deepEqual(parse('allowed-tools: [Read, Bash]', 'a.b: 1', 'my key: v'),
13
+ { 'allowed-tools': ['Read', 'Bash'], 'a.b': 1, 'my key': 'v' });
14
+ });
15
+
16
+ check('a colon without a following space is part of the value, not a key', () => {
17
+ assert.deepEqual(parse('tags:', ' - context:universal', ' - kind:rule', 'url: http://x.io/a:b'),
18
+ { tags: ['context:universal', 'kind:rule'], url: 'http://x.io/a:b' });
19
+ assert.deepEqual(parse('tags: [context:universal, b]'), { tags: ['context:universal', 'b'] });
20
+ });
21
+
22
+ check('quoted commas and brackets survive inline arrays', () => {
23
+ assert.deepEqual(parse('k: ["a", "b, c", \'d, e\', "[x]"]'), { k: ['a', 'b, c', 'd, e', '[x]'] });
24
+ });
25
+
26
+ check('JSON flow maps and arrays of JSON objects parse', () => {
27
+ assert.deepEqual(parse('decay: {"half_life_days":180,"floor":0.5}'), { decay: { half_life_days: 180, floor: 0.5 } });
28
+ assert.deepEqual(parse('x_citations: [{"source":"cli","title":"a, b","url":"https://e.io/?q=1"}]'),
29
+ { x_citations: [{ source: 'cli', title: 'a, b', url: 'https://e.io/?q=1' }] });
30
+ assert.deepEqual(parse('m: {a: 1, b: [x, y], c: {d: true}}'), { m: { a: 1, b: ['x', 'y'], c: { d: true } } });
31
+ assert.deepEqual(parse('e: {}', 'f: []'), { e: {}, f: [] });
32
+ });
33
+
34
+ check('malformed flow values fall back to the raw text', () => {
35
+ assert.deepEqual(parse('k: [a, b'), { k: '[a, b' });
36
+ });
37
+
38
+ check('wrapped plain and quoted scalars fold into one value', () => {
39
+ assert.deepEqual(parse('description: This is a long', ' description that wraps', 'next: 1'),
40
+ { description: 'This is a long description that wraps', next: 1 });
41
+ assert.deepEqual(parse('q: "one', ' two"', 'n: 2'), { q: 'one two', n: 2 });
42
+ assert.deepEqual(parse('l:', ' - first', ' second', ' - third'), { l: ['first second', 'third'] });
43
+ });
44
+
45
+ check('inline comments are stripped from plain values only', () => {
46
+ assert.deepEqual(parse('a: Ship it # todo', 'b: "keep # this"', 'c: x#y', 'd: 5 # five', 'e: # nothing'),
47
+ { a: 'Ship it', b: 'keep # this', c: 'x#y', d: 5, e: {} });
48
+ });
49
+
50
+ check('same-indent block lists and list items with block scalars parse', () => {
51
+ assert.deepEqual(parse('tags:', '- a', '- b', 'next: 1'), { tags: ['a', 'b'], next: 1 });
52
+ assert.deepEqual(parse('related:', ' - >-', ' folded', ' text', ' - plain'),
53
+ { related: ['folded text', 'plain'] });
54
+ });
55
+
56
+ check('arrays of maps keep nested children and wrapped values', () => {
57
+ assert.deepEqual(parse('items:', ' - name: x', ' sub:', ' a: 1', ' z: 2', ' - name: y'),
58
+ { items: [{ name: 'x', sub: { a: 1 }, z: 2 }, { name: 'y' }] });
59
+ assert.deepEqual(parse('items:', ' - source:', ' kind: a', ' n: 1'), { items: [{ source: { kind: 'a' }, n: 1 }] });
60
+ });
61
+
62
+ check('YAML double-quoted escapes decode (\\U, \\x, \\e)', () => {
63
+ assert.deepEqual(parse('t: "\\U0001F534 \\x41 \\e"'), { t: '\u{1F534} A \x1b' });
64
+ });
65
+
66
+ check('folded block scalars keep the break around more-indented lines', () => {
67
+ assert.deepEqual(parse('c: >', ' a', ' b', '', ' c'), { c: 'a\n b\n\nc\n' });
68
+ assert.deepEqual(parse('c: >-', ' a', ' b', '', ' c'), { c: 'a b\nc' });
69
+ });
70
+
71
+ check('numbers, codes and booleans keep their types', () => {
72
+ assert.deepEqual(parse('a: 07003', 'b: 12', 'c: 1.5', 'd: 1e3', 'e: True', 'f: NULL', 'g: 0x1F'),
73
+ { a: '07003', b: 12, c: 1.5, d: 1000, e: true, f: null, g: 31 });
74
+ });
75
+
76
+ check('unusual bare keys, nested sequences and keep-chomped blanks parse', () => {
77
+ assert.deepEqual(parse('users:', ' a@b.com:', ' n: 1', 'last*seen: x'), { users: { 'a@b.com': { n: 1 } }, 'last*seen': 'x' });
78
+ assert.deepEqual(parse('head:', ' - - meta', ' - http-equiv: refresh', ' content: go'),
79
+ { head: [['meta', { 'http-equiv': 'refresh', content: 'go' }]] });
80
+ assert.deepEqual(parse('c: |+', '', 'next: 1'), { c: '\n', next: 1 });
81
+ });
82
+
83
+ check('single-line files with literal \\n are repaired on read', () => {
84
+ const flat = '---\\ntype: "memory"\\ntags: ["a","b"]\\n---\\nBody line\\nTwo';
85
+ const { data, body } = parseDocument(flat);
86
+ assert.deepEqual(data, { type: 'memory', tags: ['a', 'b'] });
87
+ assert.equal(body, 'Body line\nTwo\n');
88
+ // A real multi-line document that merely mentions \\n is untouched.
89
+ assert.equal(parseDocument('---\nk: a\\nb\n---\n').data.k, 'a\\nb');
90
+ });
91
+
92
+ check('strict mode throws on malformed YAML that lenient mode skips', () => {
93
+ const bad = ': : :\nnot: [valid\n';
94
+ assert.doesNotThrow(() => parseFrontmatter(bad));
95
+ assert.throws(() => parseFrontmatter(bad, { strict: true }), SyntaxError);
96
+ assert.throws(() => parseFrontmatter('k: "open\nj: 1\n', { strict: true }), SyntaxError);
97
+ assert.throws(() => parseFrontmatter('a: 1\n b: 2\n', { strict: true }), SyntaxError);
98
+ assert.deepEqual(parseFrontmatter('a: [1, 2]\nb: {c: d}\n', { strict: true }), { a: [1, 2], b: { c: 'd' } });
99
+ assert.doesNotThrow(() => parseFrontmatter(bad)); // strict does not leak into later calls
100
+ });
101
+
102
+ check('serialize → parse round-trips tricky values', () => {
103
+ const data = {
104
+ 'allowed-tools': ['Read', 'Bash'],
105
+ note: 'a, b: c # d',
106
+ tags: ['context:universal', 'x, y', '-lead', '07003', 'true'],
107
+ nested: { 'odd key': { n: 1 }, list: [[1, 2], [{ a: 'b, c' }]] },
108
+ cites: [{ source: 'cli', title: 'a, b' }],
109
+ empty: [],
110
+ multi: 'line one\nline two'
111
+ };
112
+ const doc = serializeDocument(data, 'body\n');
113
+ assert.deepEqual(parseDocument(doc).data, data);
114
+ assert.equal(serializeDocument(parseDocument(doc).data, 'body\n'), doc);
115
+ });
116
+
117
+ console.log(`\n ${passed}/${passed} frontmatter parser checks passed`);
@@ -52,6 +52,9 @@ export async function run(argv) {
52
52
  version: flags['bundle-version'] || '0.1.0',
53
53
  exporter: flags.exporter || DEFAULT_EXPORTER,
54
54
  requiredExtensions: flags.ext || [],
55
+ // --ext also scopes the registry load: a registry directory may hold
56
+ // unrelated product extensions that collide by name (§5.6).
57
+ ...(flags.ext && flags.ext.length ? { registryNames: flags.ext } : {}),
55
58
  ...(flags['exported-at'] ? { exportedAt: flags['exported-at'] } : {}),
56
59
  });
57
60
  } catch (err) { die(err.message); }
@@ -31,6 +31,10 @@ const HELP = usage({
31
31
  export async function run(argv) {
32
32
  if (wantsHelp(argv)) { console.log(HELP); return; }
33
33
  const { positionals, flags } = parseArgs(argv, { booleans: ['dry-run'], multi: ['param'] });
34
+ // Extension namespaces this import must resolve. Seeded from the bundle's own
35
+ // `required_extensions` so the engine loads exactly the namespace the vault was
36
+ // exported against, rather than every extension in the registry directory.
37
+ let registryNames = null;
34
38
  if (!flags.vault) die('import needs --vault <dir>.\n\n' + HELP);
35
39
  const vault = path.resolve(flags.vault);
36
40
  fs.mkdirSync(vault, { recursive: true });
@@ -45,6 +49,8 @@ export async function run(argv) {
45
49
  if (!file) die('import needs a bundle file (or --plan).\n\n' + HELP);
46
50
  if (!fs.existsSync(file)) die(`no such file: ${file}`);
47
51
  const bundle = JSON.parse(fs.readFileSync(file, 'utf8'));
52
+ const declared = bundle?.manifest?.required_extensions;
53
+ if (Array.isArray(declared) && declared.length) registryNames = declared;
48
54
  const prov = provisionBundle(bundle, {
49
55
  parameters: parseKeyValues(flags.param),
50
56
  workspaceId: flags.workspace || 'ws-provision',
@@ -60,7 +66,7 @@ export async function run(argv) {
60
66
  envelopes = prov.plan;
61
67
  }
62
68
 
63
- const engine = createEngine({ registryDir: flags.registry });
69
+ const engine = createEngine({ registryDir: flags.registry, ...(registryNames ? { registryNames } : {}) });
64
70
  const result = await importBundle(envelopes, vault, engine);
65
71
  const failed = result.results.filter((r) => !r.success);
66
72
  if (flags['dry-run']) {
package/src/bundle.mjs CHANGED
@@ -19,6 +19,7 @@ import crypto from 'node:crypto';
19
19
  import {
20
20
  loadRegistries,
21
21
  resolvePortability,
22
+ resolvePrimitiveDefinition,
22
23
  validateDataConstraints,
23
24
  validateReferenceConstraints,
24
25
  } from './registry.mjs';
@@ -141,11 +142,15 @@ function walkVault(vaultRoot) {
141
142
  export function exportBundle(vaultRoot, opts = {}) {
142
143
  const { profile = 'backup', registryDir, name = 'untitled', description = '', version = '0.1.0',
143
144
  exporter = DEFAULT_EXPORTER, requiredExtensions = [], parameters = [], provisioning = [],
144
- sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z' } = opts;
145
+ sourceWorkspaceId, exportedAt = '1970-01-01T00:00:00.000Z', registryNames } = opts;
145
146
  const includes = PROFILE_INCLUDES[profile];
146
147
  if (!includes) throw new Error(`Unknown export profile '${profile}'. Use backup | template | sale.`);
147
148
 
148
- const { types, core } = loadRegistries(registryDir);
149
+ // When a caller names the extension namespaces this vault belongs to, load only
150
+ // those. Unrelated product extensions may share a primitive name, which is a
151
+ // legitimate load-time collision when loaded together (§5.6).
152
+ const registrySet = loadRegistries(registryDir, registryNames ? { only: registryNames } : {});
153
+ const { types, core } = registrySet;
149
154
  const dropped = [];
150
155
  const kept = [];
151
156
  const inventory = {};
@@ -161,7 +166,7 @@ export function exportBundle(vaultRoot, opts = {}) {
161
166
  for (const document of vaultDocuments.values()) {
162
167
  const base = path.basename(document.path);
163
168
  if (base === 'index.md' || base === 'log.md') continue;
164
- const definition = types.get(document.data.type);
169
+ const definition = resolvePrimitiveDefinition(registrySet, document.data.type);
165
170
  if (!definition) throw new Error(`Cannot export '${document.path}': unknown type '${document.data.type || '(missing)'}'.`);
166
171
  const issues = [
167
172
  ...validateDataConstraints(definition, document.data, core.universal_frontmatter?.required || ['type']),
@@ -237,8 +242,23 @@ export function exportBundle(vaultRoot, opts = {}) {
237
242
  */
238
243
  export function validateBundle(bundle, opts = {}) {
239
244
  const errors = [];
240
- const { types, core, extensions } = loadRegistries(opts.registryDir);
241
245
  const m = bundle && bundle.manifest;
246
+ // Scope the registry load to the extensions this bundle declares. A registry
247
+ // directory may hold unrelated product extensions that collide by name (§5.6);
248
+ // the bundle's own `required_extensions` is the authority on what it needs.
249
+ const declaredExtensions = Array.isArray(m?.required_extensions) ? m.required_extensions : [];
250
+ const only = opts.registryNames || (declaredExtensions.length && !opts.loadAllExtensions ? declaredExtensions : null);
251
+ // A bundle may name an extension this host does not have. That is a validation
252
+ // error to report, not a crash: fall back to the default composition so the
253
+ // remaining checks still run and the caller sees every problem at once.
254
+ let registrySet;
255
+ try {
256
+ registrySet = loadRegistries(opts.registryDir, only ? { only } : {});
257
+ } catch (err) {
258
+ if (!only || !/is not present in/.test(err.message)) throw err;
259
+ registrySet = loadRegistries(opts.registryDir);
260
+ }
261
+ const { types, core, extensions } = registrySet;
242
262
  if (!m || typeof m !== 'object' || Array.isArray(m)) {
243
263
  return { valid: false, errors: ['bundle has no object manifest'] };
244
264
  }
@@ -340,7 +360,7 @@ export function validateBundle(bundle, opts = {}) {
340
360
  const base = path.basename(document.path);
341
361
  if (base === 'index.md' || base === 'log.md') continue;
342
362
  const { data } = document;
343
- const def = types.get(data.type);
363
+ const def = resolvePrimitiveDefinition(registrySet, data.type);
344
364
  if (!def) {
345
365
  errors.push(`file '${document.path}' declares unknown type '${data.type || '(missing)'}'`);
346
366
  continue;
@@ -370,7 +390,7 @@ export function validateBundle(bundle, opts = {}) {
370
390
  }
371
391
  }
372
392
  for (const document of bundleDocuments.values()) {
373
- const definition = types.get(document.data.type);
393
+ const definition = resolvePrimitiveDefinition(registrySet, document.data.type);
374
394
  const issues = validateReferenceConstraints(
375
395
  definition,
376
396
  document.data,
@@ -511,12 +531,12 @@ export async function importBundle(plan, vaultRoot, engine) {
511
531
  return {
512
532
  ok: true,
513
533
  committed: 0,
514
- wouldCommit: preflight.filter((result) => !result.replay).length,
534
+ wouldCommit: preflight.filter((result) => !result.replay && !result.unchanged).length,
515
535
  results: preflight,
516
536
  };
517
537
  }
518
538
  const results = [];
519
539
  for (const env of plan) results.push(await engine.processOperation(env, vaultRoot, operationOptions));
520
- const committed = results.filter((r) => r.success && !r.replay && !r.dry_run).length;
540
+ const committed = results.filter((r) => r.success && !r.replay && !r.dry_run && !r.unchanged).length;
521
541
  return { ok: results.every((r) => r.success), committed, wouldCommit: 0, results };
522
542
  }
package/src/engine.mjs CHANGED
@@ -96,7 +96,10 @@ class LegacyLeaseAdapter {
96
96
  }
97
97
 
98
98
  export function createEngine(options = {}) {
99
- const registrySet = options.registrySet || loadRegistries(options.registryDir);
99
+ // `registryNames` scopes the load to specific extension namespaces: a registry
100
+ // directory may hold unrelated product extensions that collide by name (§5.6).
101
+ const registrySet = options.registrySet
102
+ || loadRegistries(options.registryDir, options.registryNames ? { only: options.registryNames } : {});
100
103
  const validator = options.validator || createValidator({ registrySet });
101
104
  const kernels = new Map();
102
105
  const legacyLease = typeof options.leaseStore === 'string' ? new LegacyLeaseAdapter(options.leaseStore) : options.leaseStore;
package/src/events.mjs CHANGED
@@ -126,11 +126,74 @@ export class JsonlEventStore {
126
126
  fs.mkdirSync(root, { recursive: true });
127
127
  this.root = fs.realpathSync(root);
128
128
  this.lockOptions = options.lock || {};
129
+ // Rotation cap in bytes per live log (SSSS_EVENT_MAX_BYTES, default 32 MiB,
130
+ // 0 disables). Without it a single append-only log grows without bound --
131
+ // one reached 653 MB and made the vault impossible to back up, because
132
+ // GitHub hard-rejects blobs over 100 MB.
133
+ const cap = Number(options.maxBytes ?? process.env.SSSS_EVENT_MAX_BYTES ?? 32 * 1024 * 1024);
134
+ this.maxBytes = Number.isFinite(cap) && cap >= 0 ? cap : 32 * 1024 * 1024;
129
135
  }
130
136
  #file(workspaceId) {
131
137
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(workspaceId || '')) throw new Error('Unsafe event workspace_id.');
132
138
  return path.join(this.root, `${workspaceId}.jsonl`);
133
139
  }
140
+ /**
141
+ * Archive segments for one workspace, in replay order.
142
+ *
143
+ * Segments are named `<workspaceId>.<seq>.jsonl` with a fixed-width
144
+ * zero-padded sequence, and replay() concatenates live-then-archives rather
145
+ * than relying on a plain alphabetical sort -- `default.0001.jsonl` sorts
146
+ * BEFORE `default.jsonl`, which would replay archived events out of order.
147
+ */
148
+ #archives(workspaceId) {
149
+ const prefix = `${workspaceId}.`;
150
+ const suffix = '.jsonl';
151
+ let names;
152
+ try { names = fs.readdirSync(this.root); } catch { return []; }
153
+ return names
154
+ .filter((n) => n.startsWith(prefix) && n.endsWith(suffix) && /^\d+$/.test(n.slice(prefix.length, -suffix.length)))
155
+ .sort((a, b) => {
156
+ const ai = Number(a.slice(prefix.length, -suffix.length));
157
+ const bi = Number(b.slice(prefix.length, -suffix.length));
158
+ return ai - bi;
159
+ })
160
+ .map((n) => path.join(this.root, n));
161
+ }
162
+ /**
163
+ * Moves the live log aside once it passes the cap, preserving every byte and
164
+ * the original line order. The live file is renamed, never rewritten or
165
+ * truncated, so no consumer holding a cursor loses a record: archives hold
166
+ * strictly older events and replay() yields archives first, then live.
167
+ *
168
+ * The duplicate-event_id index is rebuilt from the archives on demand by
169
+ * #index(), so uniqueness survives rotation.
170
+ */
171
+ #rotate(file, workspaceId) {
172
+ if (this.maxBytes <= 0) return 0;
173
+ let size;
174
+ try { size = fs.lstatSync(file).size; } catch { return 0; }
175
+ if (size <= this.maxBytes) return 0;
176
+ // Continue the sequence from the highest existing archive rather than the
177
+ // count, so a gap (a manually removed segment) cannot cause a collision.
178
+ const existing = this.#archives(workspaceId);
179
+ let seq = 1;
180
+ for (const p of existing) {
181
+ const m = /\.(\d+)\.jsonl$/.exec(p);
182
+ if (m) seq = Math.max(seq, Number(m[1]) + 1);
183
+ }
184
+ const target = path.join(this.root, `${workspaceId}.${String(seq).padStart(4, '0')}.jsonl`);
185
+ if (fs.existsSync(target)) return 0;
186
+ try {
187
+ fs.renameSync(file, target);
188
+ this.#indexes.delete(file);
189
+ this.#indexes.delete(target);
190
+ return 1;
191
+ } catch {
192
+ // Leave the oversized log in place rather than lose it; the next append
193
+ // retries. Growth stays bounded by the cap check on every append.
194
+ return 0;
195
+ }
196
+ }
134
197
  #index(file) {
135
198
  let stat;
136
199
  try { stat = fs.lstatSync(file); }
@@ -142,7 +205,21 @@ export class JsonlEventStore {
142
205
  if (stat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
143
206
  const cached = this.#indexes.get(file);
144
207
  if (cached && sameStat(cached.stat, stat)) return cached;
145
- const index = loadIndex(file);
208
+ // Duplicate detection spans the live log AND every archive segment:
209
+ // rotating a log must never let an already-recorded event_id be appended
210
+ // again. Archives are immutable, so their ids are collected once per
211
+ // rebuild; only the live file's stat decides when a rebuild is needed.
212
+ const ids = new Set();
213
+ const workspaceId = path.basename(file, '.jsonl');
214
+ for (const archive of this.#archives(workspaceId)) {
215
+ let archiveStat;
216
+ try { archiveStat = fs.lstatSync(archive); } catch { continue; }
217
+ if (archiveStat.isSymbolicLink()) throw new Error('Symlinked event logs are forbidden.');
218
+ for (const id of loadIndex(archive).ids) ids.add(id);
219
+ }
220
+ const live = loadIndex(file);
221
+ for (const id of live.ids) ids.add(id);
222
+ const index = { ids, stat };
146
223
  this.#indexes.set(file, index);
147
224
  return index;
148
225
  }
@@ -153,29 +230,44 @@ export class JsonlEventStore {
153
230
  #appendLocked(file, event) {
154
231
  const index = this.#index(file);
155
232
  if (index.ids.has(event.event_id)) throw new Error(`Duplicate event_id '${event.event_id}'.`);
233
+ // Rotate before writing so the live log never exceeds the cap by more than
234
+ // a single line. Safe under the append lock: no other writer is mid-append.
235
+ const rotated = this.#rotate(file, event.workspace_id);
156
236
  const line = `${JSON.stringify(event)}\n`;
157
237
  this.#indexes.delete(file);
238
+ const before = rotated ? null : index.stat;
158
239
  const fd = openLog(file, APPEND_FLAGS, 0o600);
159
240
  let after;
160
241
  try {
161
242
  fs.writeFileSync(fd, line);
162
243
  after = fs.fstatSync(fd);
163
244
  } finally { fs.closeSync(fd); }
164
- // Keep the index only when the log grew by exactly this line on the same
165
- // file; otherwise a writer that skips the lock interleaved, and the next
166
- // append rebuilds.
167
- const before = index.stat;
245
+ // Cache the index only when the log grew by exactly this line on the same
246
+ // file. After a rotation the live file is brand new, so the previous stat
247
+ // is meaningless and the next append rebuilds from the archives instead.
168
248
  const expectedSize = (before?.size || 0) + Buffer.byteLength(line);
169
- if (after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
249
+ if (!rotated && after.size === expectedSize && (!before || (before.dev === after.dev && before.ino === after.ino))) {
170
250
  index.ids.add(event.event_id);
171
251
  this.#indexes.set(file, { ids: index.ids, stat: after });
172
252
  }
173
253
  return structuredClone(event);
174
254
  }
175
255
  async *replay(options = {}) {
256
+ // Replay order is archives-then-live, oldest first. A plain alphabetical
257
+ // sort would put `default.0001.jsonl` before `default.jsonl` (correct by
258
+ // accident) but break as soon as a workspace has a name that sorts after a
259
+ // digit, and live-first would replay new events before old ones.
176
260
  const files = options.workspaceId
177
- ? [this.#file(options.workspaceId)]
178
- : fs.readdirSync(this.root).filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(this.root, name));
261
+ ? [...this.#archives(options.workspaceId), this.#file(options.workspaceId)]
262
+ : (() => {
263
+ const names = fs.readdirSync(this.root).filter((name) => name.endsWith('.jsonl'));
264
+ const workspaces = new Set();
265
+ for (const name of names) {
266
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,127}?)(?:\.\d+)?\.jsonl$/.exec(name);
267
+ if (m) workspaces.add(m[1]);
268
+ }
269
+ return [...workspaces].sort().flatMap((w) => [...this.#archives(w), this.#file(w)]);
270
+ })();
179
271
  let cursor = 0;
180
272
  for (const file of files) {
181
273
  if (!fs.existsSync(file)) continue;