@gregiteen/ssss-cli 0.11.2 → 0.12.0

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-10-29
11
+ ### Added
12
+ - `FileIdempotencyStore` now expires entries. It accepts `retentionMs` (default
13
+ 24 hours, `0` disables) and `pruneEvery` (default every 500 puts), and exposes
14
+ `prune()`, which removes entries older than the window and returns the count.
15
+ Retention runs opportunistically inside `put` and never rejects a committed
16
+ operation. Previously entries were written and never removed, so a directory
17
+ only grew: one frequently rewritten document produced 14,438 records from a
18
+ single path, and a live vault reached 283k files / 1.2 GB, which slowed every
19
+ traversal of it. A retry arrives within minutes, so a bounded window loses
20
+ nothing that mattered. Lock and temp files are never treated as entries.
21
+
10
22
  ## [0.11.2] - 2026-10-19
11
23
  ### Added
12
24
  - `parseFrontmatter(fm, { strict: true })` throws a `SyntaxError` on lines that
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.2
1
+ 0.12.0
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.11.2"
109
+ "exporter": "@gregiteen/ssss-cli@0.12.0"
110
110
  }
111
111
  },
112
112
  "files": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
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,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
2
3
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -123,6 +124,32 @@ async function checkFileAdapterRaces(root, principal, check) {
123
124
  check('filesystem idempotency store accepts one of several processes putting the same key',
124
125
  winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
125
126
 
127
+ // Retention: an entry exists to reject a retry, which arrives within minutes,
128
+ // so entries expire rather than living forever. Without this a single
129
+ // frequently rewritten document produced 14,438 records and a live vault
130
+ // reached 283k files / 1.2 GB, slowing every traversal of the vault.
131
+ const retentionRoot = path.join(root, 'idempotency-retention');
132
+ const retention = new FileIdempotencyStore(retentionRoot, { retentionMs: 60_000, pruneEvery: 1 });
133
+ await retention.put('acme', 'fresh', { request_hash: 'sha256:fresh' });
134
+ const entryName = (workspaceId, idempotencyKey) =>
135
+ `${crypto.createHash('sha256').update(JSON.stringify([workspaceId, idempotencyKey])).digest('hex')}.json`;
136
+ const staleFile = path.join(retentionRoot, entryName('acme', 'stale'));
137
+ fs.writeFileSync(staleFile, '{"request_hash":"sha256:stale"}\n', { mode: 0o600 });
138
+ const aged = new Date(Date.now() - 10 * 60 * 1000);
139
+ fs.utimesSync(staleFile, aged, aged);
140
+ const removed = retention.prune();
141
+ check('filesystem idempotency store expires entries past the retention window',
142
+ removed === 1 && !fs.existsSync(staleFile), `removed ${removed}`);
143
+ check('filesystem idempotency store keeps entries inside the retention window',
144
+ (await retention.get('acme', 'fresh'))?.request_hash === 'sha256:fresh');
145
+ check('filesystem idempotency store skips retention when the window is disabled',
146
+ new FileIdempotencyStore(retentionRoot, { retentionMs: 0 }).prune() === 0);
147
+ const lockName = path.join(retentionRoot, `${entryName('acme', 'locked')}.lock`);
148
+ fs.writeFileSync(lockName, '');
149
+ const lockSurvived = retention.prune() === 0 && fs.existsSync(lockName);
150
+ fs.rmSync(lockName, { force: true });
151
+ check('filesystem idempotency store never treats a lock file as an entry', lockSurvived);
152
+
126
153
  const kernelRoot = path.join(root, 'kernel');
127
154
  const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
128
155
  const stores = () => ({
@@ -346,6 +346,35 @@ async function runBundleConformance() {
346
346
  fs.rmSync(atomicVault, { recursive: true, force: true });
347
347
  }
348
348
 
349
+ const referenceVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-refs-'));
350
+ const missingVault = fs.mkdtempSync(path.join(os.tmpdir(), 'ssss-bundle-missing-'));
351
+ try {
352
+ const engine = createEngine({ registryDir: REGISTRY_DIR });
353
+ const envelope = (id, target, fields) => ({
354
+ type: 'operation', workspace_id: 'ws-refs', idempotency_key: id,
355
+ path: target, actor: { role: 'system' },
356
+ content: ['---', 'title: Reference test', 'description: Complete planned references.',
357
+ 'timestamp: 2026-10-28T00:00:00Z', ...fields, '---', '', 'Exact payload.', ''].join('\n'),
358
+ });
359
+ const asset = envelope('ref-asset', 'assets/source.md', [
360
+ 'type: asset', 'name: source', 'mime_type: text/plain', 'encoding: utf-8',
361
+ 'resource_ref: resources/store.md',
362
+ ]);
363
+ const resource = envelope('ref-resource', 'resources/store.md', [
364
+ 'type: resource', 'name: store', 'kind: storage', 'status: bound',
365
+ ]);
366
+ const imported = await importBundle([asset, resource], referenceVault, engine);
367
+ check('fresh import resolves a forward reference from the complete plan',
368
+ imported.ok && imported.committed === 2,
369
+ JSON.stringify(imported.results.map(item => item.validation?.errors || [])));
370
+ const missing = await importBundle([asset], missingVault, engine);
371
+ check('missing planned reference rejects import without partial writes',
372
+ !missing.ok && missing.committed === 0 && !fs.existsSync(path.join(missingVault, 'assets/source.md')));
373
+ } finally {
374
+ fs.rmSync(referenceVault, { recursive: true, force: true });
375
+ fs.rmSync(missingVault, { recursive: true, force: true });
376
+ }
377
+
349
378
  let pass = 0;
350
379
  for (const c of checks) {
351
380
  if (c.cond) { pass++; console.log(` ✅ ${c.name}`); }
@@ -42,6 +42,11 @@ When an SSSS engine processes a write, it receives an **Envelope**:
42
42
  ### 4. Bundles (.ucw)
43
43
  An SSSS vault can be packaged into a `.ucw` (Universal Containerized Workspace) bundle. A bundle is a portable snapshot containing a manifest, deterministic file paths, and content hashes for cryptographic integrity.
44
44
 
45
+ Bundle import validates the complete plan before committing. Hosts pass planned
46
+ document lookup through the kernel context's `resolveReference` option so valid
47
+ forward references resolve in an empty destination. Missing referenced documents
48
+ must reject the whole import; never weaken constraints to bypass ordering.
49
+
45
50
  ## Validation
46
51
 
47
52
  If you are writing tests or schemas in a host repository, ensure that:
package/src/bundle.mjs CHANGED
@@ -508,7 +508,7 @@ export async function importBundle(plan, vaultRoot, engine) {
508
508
  });
509
509
  }
510
510
  const operationOptions = {
511
- referenceResolver: (referencePath) => plannedDocuments.get(referencePath),
511
+ resolveReference: (referencePath) => plannedDocuments.get(referencePath),
512
512
  };
513
513
  // Two-phase import: validate the complete plan with in-plan references first.
514
514
  // A single invalid document prevents every commit, eliminating partial bundle
@@ -6,6 +6,17 @@ import { withFileLock } from './file-lock.mjs';
6
6
 
7
7
  function key(workspaceId, idempotencyKey) { return JSON.stringify([workspaceId, idempotencyKey]); }
8
8
 
9
+ /**
10
+ * How long a completed operation stays replayable. A retry of the same request
11
+ * arrives within minutes (a network timeout, an agent restart); nothing needs a
12
+ * day-old result, and retaining them forever is what filled a live vault with
13
+ * 283k files. Override per store with `new FileIdempotencyStore(root, { retentionMs })`.
14
+ */
15
+ export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
16
+
17
+ /** Prune once every N puts, so retention costs one directory scan per N writes. */
18
+ export const DEFAULT_PRUNE_EVERY = 500;
19
+
9
20
  export class MemoryIdempotencyStore {
10
21
  #entries = new Map();
11
22
  async get(workspaceId, idempotencyKey) { return structuredClone(this.#entries.get(key(workspaceId, idempotencyKey)) || null); }
@@ -21,6 +32,18 @@ export class FileIdempotencyStore {
21
32
  fs.mkdirSync(root, { recursive: true });
22
33
  this.root = fs.realpathSync(root);
23
34
  this.lockOptions = options.lock || {};
35
+ // An idempotency entry exists to reject a retry of the same request. That
36
+ // window is minutes, not forever, so entries are retained for a bounded age
37
+ // and then removed. Without this the directory only grows: one frequently
38
+ // rewritten document created 14,438 records from a single path, and a live
39
+ // vault reached 283k files / 1.2 GB, which slowed every traversal of it.
40
+ this.retentionMs = Number.isFinite(options.retentionMs) && options.retentionMs >= 0
41
+ ? options.retentionMs
42
+ : DEFAULT_RETENTION_MS;
43
+ this.pruneEvery = Number.isInteger(options.pruneEvery) && options.pruneEvery > 0
44
+ ? options.pruneEvery
45
+ : DEFAULT_PRUNE_EVERY;
46
+ this.puts = 0;
24
47
  }
25
48
  #file(workspaceId, idempotencyKey) {
26
49
  const digest = crypto.createHash('sha256').update(key(workspaceId, idempotencyKey)).digest('hex');
@@ -44,8 +67,37 @@ export class FileIdempotencyStore {
44
67
  fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
45
68
  fs.renameSync(temp, file);
46
69
  } finally { fs.rmSync(temp, { force: true }); }
70
+ // Opportunistic retention: no scheduler, and the cost is amortised over
71
+ // putPushes. Never throws — a prune failure must not reject a committed
72
+ // operation, whose result the caller is about to rely on.
73
+ this.puts += 1;
74
+ if (this.retentionMs > 0 && this.puts % this.pruneEvery === 0) {
75
+ try { this.prune(); } catch { /* retention is best-effort */ }
76
+ }
47
77
  }, this.lockOptions);
48
78
  }
79
+ /**
80
+ * Remove entries older than the retention window. Returns the count removed.
81
+ * Only regular `*.json` files are considered; lock files and temp files are
82
+ * left alone so a concurrent writer is never disturbed.
83
+ */
84
+ prune(now = Date.now()) {
85
+ if (!(this.retentionMs > 0)) return 0;
86
+ const cutoff = now - this.retentionMs;
87
+ let removed = 0;
88
+ let entries;
89
+ try { entries = fs.readdirSync(this.root); } catch { return 0; }
90
+ for (const name of entries) {
91
+ if (!name.endsWith('.json')) continue;
92
+ const file = path.join(this.root, name);
93
+ try {
94
+ const stat = fs.lstatSync(file);
95
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
96
+ if (stat.mtimeMs < cutoff) { fs.rmSync(file, { force: true }); removed += 1; }
97
+ } catch { /* a vanished or unreadable entry is not a prune failure */ }
98
+ }
99
+ return removed;
100
+ }
49
101
  }
50
102
 
51
103
  export async function runIdempotencyContract(store) {