@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.
- package/CHANGELOG.md +28 -0
- package/VERSION +1 -1
- package/conformance/reference-bundle.ucw.json +1 -1
- package/docs/ssss-spec.md +15 -3
- package/package.json +2 -2
- package/registry/extensions/defaults.json +5 -0
- package/registry/extensions/festech.json +1055 -74
- package/registry/extensions/field-crm.json +1018 -0
- package/scripts/audit-registry-field-usage.mjs +6 -0
- package/scripts/autolink.mjs +8 -13
- package/scripts/check-events-streaming.mjs +46 -1
- package/scripts/check-frontmatter.mjs +117 -0
- package/scripts/cmd-export.mjs +3 -0
- package/scripts/cmd-import.mjs +7 -1
- package/src/bundle.mjs +28 -8
- package/src/engine.mjs +4 -1
- package/src/events.mjs +100 -8
- package/src/frontmatter.mjs +274 -59
- package/src/kernel.mjs +22 -0
- package/src/registry.mjs +48 -6
|
@@ -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() {
|
package/scripts/autolink.mjs
CHANGED
|
@@ -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
|
|
89
|
+
/** Pull title / name / slug / aliases[] out of a frontmatter block (fences included). */
|
|
89
90
|
function readMeta(fm) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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`);
|
package/scripts/cmd-export.mjs
CHANGED
|
@@ -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); }
|
package/scripts/cmd-import.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
165
|
-
// file
|
|
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
|
-
:
|
|
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;
|