@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.
@@ -10,8 +10,20 @@
10
10
  * operation patches without dropping nested frontmatter.
11
11
  */
12
12
 
13
+ /**
14
+ * Repair a document a broken writer flattened onto one line with literal `\n`
15
+ * text in place of newlines (`---\ntype: "memory"\n...`). Real documents with
16
+ * any actual line break are returned unchanged.
17
+ */
18
+ export function repairEscapedNewlines(content) {
19
+ if (typeof content !== 'string' || !content.startsWith('---\\n') || content.trim().includes('\n')) return content;
20
+ const fixed = content.replace(/\\n/g, '\n');
21
+ return fixed.endsWith('\n') ? fixed : `${fixed}\n`;
22
+ }
23
+
13
24
  /** Split a document into its raw frontmatter block and body. */
14
25
  export function splitFrontmatter(content) {
26
+ content = repairEscapedNewlines(content);
15
27
  if (typeof content !== 'string' || !content.startsWith('---')) {
16
28
  return { fm: '', body: content || '', hasFm: false };
17
29
  }
@@ -25,36 +37,199 @@ export function splitFrontmatter(content) {
25
37
  return { fm: inner, body: content.slice(sliceEnd), hasFm: true };
26
38
  }
27
39
 
28
- function coerce(raw) {
29
- const v = raw.trim();
30
- if (v === '') return '';
31
- if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
40
+ function countIndent(line) {
41
+ const m = line.match(/^ */);
42
+ return m ? m[0].length : 0;
43
+ }
44
+
45
+ /** Index of the quote that closes the quoted scalar starting at `v[0]`, or -1. */
46
+ function findClosingQuote(v) {
47
+ const q = v[0];
48
+ for (let i = 1; i < v.length; i++) {
49
+ if (q === '"') {
50
+ if (v[i] === '\\') i++;
51
+ else if (v[i] === '"') return i;
52
+ } else if (v[i] === "'") {
53
+ if (v[i + 1] === "'") i++;
54
+ else return i;
55
+ }
56
+ }
57
+ return -1;
58
+ }
59
+
60
+ const YAML_ESCAPES = {
61
+ 0: '\0', a: '\x07', b: '\b', t: '\t', '\t': '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b',
62
+ ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029'
63
+ };
64
+
65
+ /** Decode a YAML double-quoted scalar body (a superset of JSON string escapes). */
66
+ function unescapeDouble(body) {
67
+ return body.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|([\s\S]))/g, (m, x, u, U, c) => {
68
+ const hex = x ?? u ?? U;
69
+ if (hex) {
70
+ const cp = parseInt(hex, 16);
71
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
72
+ }
73
+ return Object.hasOwn(YAML_ESCAPES, c) ? YAML_ESCAPES[c] : m;
74
+ });
75
+ }
76
+
77
+ function unquote(tok) {
78
+ if (tok[0] === '"') {
32
79
  // Double-quoted scalars carry escapes; the serializer writes them with JSON.stringify.
33
- try { return JSON.parse(v); } catch { return v.slice(1, -1); }
80
+ try { return JSON.parse(tok); } catch { return unescapeDouble(tok.slice(1, -1)); }
34
81
  }
35
- if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
36
- if (v === 'true') return true;
37
- if (v === 'false') return false;
38
- if (v === 'null' || v === '~') return null;
82
+ return tok.slice(1, -1).replace(/''/g, "'");
83
+ }
84
+
85
+ /** Drop a trailing ` # comment` from an unquoted value. */
86
+ function stripComment(v) {
87
+ return v.replace(/(^|\s)#.*$/, '').trimEnd();
88
+ }
89
+
90
+ const INT_RE = /^[-+]?(?:0|[1-9]\d*)$/;
91
+ const FLOAT_RE = /^[-+]?(?:(?:0|[1-9]\d*)?\.\d+(?:[eE][-+]?\d+)?|(?:0|[1-9]\d*)(?:\.\d*)?[eE][-+]?\d+)$/;
92
+
93
+ function coercePlain(v) {
94
+ if (v === 'true' || v === 'True' || v === 'TRUE') return true;
95
+ if (v === 'false' || v === 'False' || v === 'FALSE') return false;
96
+ if (v === 'null' || v === 'Null' || v === 'NULL' || v === '~') return null;
39
97
  // A leading zero (zip 07003, license 00123) marks a code, not a number.
40
- if (/^-?(0|[1-9]\d*)$/.test(v)) return parseInt(v, 10);
41
- if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
42
- if (v.startsWith('[') && v.endsWith(']')) {
43
- const inner = v.slice(1, -1).trim();
44
- if (!inner) return [];
45
- return inner.split(',').map((s) => coerce(s));
46
- }
98
+ if (INT_RE.test(v)) return parseInt(v, 10);
99
+ if (/^0x[0-9a-fA-F]+$/.test(v)) return parseInt(v.slice(2), 16);
100
+ if (/^0o[0-7]+$/.test(v)) return parseInt(v.slice(2), 8);
101
+ if (FLOAT_RE.test(v)) return parseFloat(v);
47
102
  return v;
48
103
  }
49
104
 
50
- function countIndent(line) {
51
- const m = line.match(/^ */);
52
- return m ? m[0].length : 0;
105
+ /**
106
+ * Parse an inline `[...]` / `{...}` value (JSON and YAML flow style). Quotes,
107
+ * nested brackets and commas inside quoted strings are respected. Throws on
108
+ * malformed input so the caller can fall back to the raw text.
109
+ */
110
+ function parseFlow(src) {
111
+ let pos = 0;
112
+ const ws = () => { while (pos < src.length && /\s/.test(src[pos])) pos++; };
113
+ const quoted = () => {
114
+ const end = findClosingQuote(src.slice(pos));
115
+ if (end < 0) throw new Error('unterminated quote');
116
+ const tok = src.slice(pos, pos + end + 1);
117
+ pos += end + 1;
118
+ return unquote(tok);
119
+ };
120
+ const plain = (inKey) => {
121
+ const start = pos;
122
+ while (pos < src.length) {
123
+ const ch = src[pos];
124
+ if (ch === ',' || ch === ']' || ch === '}') break;
125
+ if (inKey && ch === ':' && (pos + 1 >= src.length || /[\s,\]}]/.test(src[pos + 1]))) break;
126
+ pos++;
127
+ }
128
+ return src.slice(start, pos).trim();
129
+ };
130
+ const node = () => {
131
+ ws();
132
+ const ch = src[pos];
133
+ if (ch === '[') return seq();
134
+ if (ch === '{') return map();
135
+ if (ch === '"' || ch === "'") return quoted();
136
+ const p = plain(false);
137
+ return p === '' ? null : coercePlain(p);
138
+ };
139
+ const seq = () => {
140
+ pos++;
141
+ const out = [];
142
+ for (;;) {
143
+ ws();
144
+ if (pos >= src.length) throw new Error('unterminated sequence');
145
+ if (src[pos] === ']') { pos++; return out; }
146
+ out.push(node());
147
+ ws();
148
+ if (src[pos] === ',') pos++;
149
+ else if (src[pos] !== ']') throw new Error('bad sequence');
150
+ }
151
+ };
152
+ const map = () => {
153
+ pos++;
154
+ const out = {};
155
+ for (;;) {
156
+ ws();
157
+ if (pos >= src.length) throw new Error('unterminated map');
158
+ if (src[pos] === '}') { pos++; return out; }
159
+ const key = src[pos] === '"' || src[pos] === "'" ? quoted() : plain(true);
160
+ ws();
161
+ if (src[pos] === ':') { pos++; out[key] = node(); } else out[key] = null;
162
+ ws();
163
+ if (src[pos] === ',') pos++;
164
+ else if (src[pos] !== '}') throw new Error('bad map');
165
+ }
166
+ };
167
+ const value = node();
168
+ ws();
169
+ if (pos < src.length && !src.slice(pos).startsWith('#')) throw new Error('trailing text');
170
+ return value;
171
+ }
172
+
173
+ // Set by parseFrontmatter(fm, { strict: true }): malformed input throws instead of
174
+ // being skipped or kept as raw text.
175
+ let strictMode = false;
176
+
177
+ function coerce(raw) {
178
+ const v = raw.trim();
179
+ if (v === '') return '';
180
+ if (v[0] === '"' || v[0] === "'") {
181
+ const end = findClosingQuote(v);
182
+ if (end > 0 && /^\s*(#.*)?$/.test(v.slice(end + 1))) return unquote(v.slice(0, end + 1));
183
+ if (strictMode) throw new SyntaxError(`Malformed quoted value: ${v.slice(0, 60)}`);
184
+ return v;
185
+ }
186
+ if (v[0] === '[' || v[0] === '{') {
187
+ try { return parseFlow(v); } catch (err) {
188
+ if (strictMode) throw new SyntaxError(`Malformed inline value: ${v.slice(0, 60)}`);
189
+ return v;
190
+ }
191
+ }
192
+ return coercePlain(stripComment(v));
53
193
  }
54
194
 
195
+ // A key is a bare word (no whitespace, `:` or `#`; inner single spaces allowed)
196
+ // or a quoted string, followed by `:` and then whitespace or end of line. The
197
+ // whitespace requirement keeps tags like `context:universal` and URLs as values.
198
+ const KEY_RE =
199
+ /^(?:([A-Za-z0-9_][^\s:#]*(?: [^\s:#]+)*)|("(?:[^"\\]|\\.)*")|('(?:[^']|'')*')):(?:[ \t]+(.*))?$/;
200
+
55
201
  function splitKeyValue(text) {
56
- const m = text.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
57
- return m ? { key: m[1], rest: m[2] } : null;
202
+ const m = text.trimEnd().match(KEY_RE);
203
+ if (!m) return null;
204
+ const key = m[1] ?? unquote(m[2] ?? m[3]);
205
+ const rest = (m[4] ?? '').trim();
206
+ return { key, rest: rest.startsWith('#') ? '' : rest };
207
+ }
208
+
209
+ /**
210
+ * Read a scalar or flow value that starts on `lines[i]` (already split into
211
+ * `rest`) and may wrap onto deeper-indented continuation lines. Plain and
212
+ * quoted lines fold with a space; a blank line is one newline.
213
+ */
214
+ function readScalar(lines, i, keyIndent, rest) {
215
+ const inline = /^["'[{]/.test(rest);
216
+ let text = rest;
217
+ let j = i + 1;
218
+ let blanks = 0;
219
+ let last = i;
220
+ while (j < lines.length) {
221
+ const line = lines[j];
222
+ if (!line.trim()) { blanks++; j++; continue; }
223
+ if (countIndent(line) <= keyIndent) break;
224
+ if (!inline && line.trim().startsWith('#')) break;
225
+ if (strictMode && !inline && splitKeyValue(line.trim())) throw new SyntaxError(`Unexpected key in a wrapped value: ${line.trim().slice(0, 60)}`);
226
+ const piece = inline ? line.trim() : stripComment(line.trim());
227
+ text += blanks ? '\n'.repeat(blanks) + piece : ` ${piece}`;
228
+ blanks = 0;
229
+ last = j;
230
+ j++;
231
+ }
232
+ return { value: coerce(text), next: last + 1 };
58
233
  }
59
234
 
60
235
  function nextSignificant(lines, start) {
@@ -64,7 +239,7 @@ function nextSignificant(lines, start) {
64
239
  return -1;
65
240
  }
66
241
 
67
- const BLOCK_SCALAR_RE = /^([|>])([+-]?)$/;
242
+ const BLOCK_SCALAR_RE = /^([|>])([+-]?)(?:\s+#.*)?$/;
68
243
 
69
244
  /** Read a `|` (literal) or `>` (folded) block scalar with clip/strip/keep chomping. */
70
245
  function parseBlockScalar(lines, start, parentIndent, style, chomp) {
@@ -84,23 +259,26 @@ function parseBlockScalar(lines, start, parentIndent, style, chomp) {
84
259
  while (end > 0 && raw[end - 1] === '') end--;
85
260
  const content = raw.slice(0, end);
86
261
  let text = content[0] ?? '';
262
+ let prevText = 0; // index of the last non-blank line already emitted
87
263
  for (let k = 1; k < content.length; k++) {
88
264
  const line = content[k];
89
- const previous = content[k - 1];
90
- if (style === '|') text += `\n${line}`;
91
- // Folding: each blank line is one newline, and the break before it is
92
- // consumed; more-indented lines keep their breaks; others join with a space.
93
- else if (line === '') text += '\n';
94
- else if (previous === '') text += line;
95
- else if (/^\s/.test(line) || /^\s/.test(previous)) text += `\n${line}`;
96
- else text += ` ${line}`;
265
+ if (line === '') continue;
266
+ const blanks = k - prevText - 1;
267
+ if (style === '|') text += `${'\n'.repeat(blanks + 1)}${line}`;
268
+ // Folding: a break between two ordinary lines becomes a space, and blank
269
+ // lines each add one newline; a more-indented line on either side keeps its break.
270
+ else if (/^\s/.test(line) || /^\s/.test(content[prevText])) text += `${'\n'.repeat(blanks + 1)}${line}`;
271
+ else text += blanks ? `${'\n'.repeat(blanks)}${line}` : ` ${line}`;
272
+ prevText = k;
97
273
  }
98
- if (!content.length) text = '';
274
+ if (!content.length) text = chomp === '+' ? '\n'.repeat(raw.length) : '';
99
275
  else if (chomp === '+') text += '\n'.repeat(1 + raw.length - end);
100
276
  else if (chomp !== '-') text += '\n';
101
277
  return { value: text, next: i };
102
278
  }
103
279
 
280
+ const isDash = (text) => text === '-' || text.startsWith('- ');
281
+
104
282
  function parseMap(lines, start, indent) {
105
283
  const data = {};
106
284
  let i = start;
@@ -109,9 +287,17 @@ function parseMap(lines, start, indent) {
109
287
  if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
110
288
  const currentIndent = countIndent(line);
111
289
  if (currentIndent < indent) break;
112
- if (currentIndent > indent) { i++; continue; }
290
+ if (currentIndent > indent) {
291
+ if (strictMode) throw new SyntaxError(`Unexpected indentation: ${line.trim().slice(0, 60)}`);
292
+ i++;
293
+ continue;
294
+ }
113
295
  const kv = splitKeyValue(line.slice(indent));
114
- if (!kv) { i++; continue; }
296
+ if (!kv) {
297
+ if (strictMode) throw new SyntaxError(`Not a "key: value" line: ${line.trim().slice(0, 60)}`);
298
+ i++;
299
+ continue;
300
+ }
115
301
  const block = kv.rest.match(BLOCK_SCALAR_RE);
116
302
  if (block) {
117
303
  const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
@@ -120,19 +306,22 @@ function parseMap(lines, start, indent) {
120
306
  continue;
121
307
  }
122
308
  if (kv.rest !== '') {
123
- data[kv.key] = coerce(kv.rest);
124
- i++;
309
+ const scalar = readScalar(lines, i, indent, kv.rest);
310
+ data[kv.key] = scalar.value;
311
+ i = scalar.next;
125
312
  continue;
126
313
  }
127
314
 
128
315
  const next = nextSignificant(lines, i + 1);
129
- if (next < 0 || countIndent(lines[next]) <= indent) {
316
+ // YAML allows a block list at the same indent as its key.
317
+ const sameIndentList = next >= 0 && countIndent(lines[next]) === indent && isDash(lines[next].slice(indent));
318
+ if (next < 0 || (countIndent(lines[next]) <= indent && !sameIndentList)) {
130
319
  data[kv.key] = {};
131
320
  i++;
132
321
  continue;
133
322
  }
134
323
  const childIndent = countIndent(lines[next]);
135
- if (lines[next].slice(childIndent).startsWith('- ')) {
324
+ if (isDash(lines[next].slice(childIndent))) {
136
325
  const parsed = parseList(lines, next, childIndent);
137
326
  data[kv.key] = parsed.value;
138
327
  i = parsed.next;
@@ -154,21 +343,25 @@ function parseList(lines, start, indent) {
154
343
  const currentIndent = countIndent(line);
155
344
  if (currentIndent < indent) break;
156
345
  if (currentIndent > indent) { i++; continue; }
157
- const item = line.slice(indent).match(/^-\s*(.*)$/);
346
+ const item = line.slice(indent).match(/^-(?:(\s+)(.*))?$/);
158
347
  if (!item) break;
159
- const raw = item[1];
348
+ const raw = (item[2] ?? '').trim();
349
+ const keyIndent = indent + 1 + (item[1] ?? '').length;
160
350
  const kv = splitKeyValue(raw);
161
351
  if (kv) {
162
- const obj = { [kv.key]: kv.rest === '' ? {} : coerce(kv.rest) };
163
- i++;
164
- const next = nextSignificant(lines, i);
165
- if (next >= 0 && countIndent(lines[next]) > indent) {
166
- const parsed = parseMap(lines, next, countIndent(lines[next]));
167
- Object.assign(obj, parsed.value);
168
- i = parsed.next;
169
- }
170
- items.push(obj);
171
- } else if (raw === '') {
352
+ // Re-read the item as a map whose first key sits where the dash was, so
353
+ // nested children, block scalars and wrapped values of every key parse alike.
354
+ lines[i] = ' '.repeat(keyIndent) + raw;
355
+ const parsed = parseMap(lines, i, keyIndent);
356
+ items.push(parsed.value);
357
+ i = parsed.next;
358
+ } else if (isDash(raw)) {
359
+ // Nested sequence (`- - a`): re-read the inner dash as its own list item.
360
+ lines[i] = ' '.repeat(keyIndent) + raw;
361
+ const parsed = parseList(lines, i, keyIndent);
362
+ items.push(parsed.value);
363
+ i = parsed.next;
364
+ } else if (raw === '' || raw.startsWith('#')) {
172
365
  const next = nextSignificant(lines, i + 1);
173
366
  if (next >= 0 && countIndent(lines[next]) > indent) {
174
367
  const parsed = parseMap(lines, next, countIndent(lines[next]));
@@ -178,21 +371,36 @@ function parseList(lines, start, indent) {
178
371
  items.push('');
179
372
  i++;
180
373
  }
374
+ } else if (BLOCK_SCALAR_RE.test(raw)) {
375
+ const block = raw.match(BLOCK_SCALAR_RE);
376
+ const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
377
+ items.push(parsed.value);
378
+ i = parsed.next;
181
379
  } else {
182
- items.push(coerce(raw));
183
- i++;
380
+ const scalar = readScalar(lines, i, indent, raw);
381
+ items.push(scalar.value);
382
+ i = scalar.next;
184
383
  }
185
384
  }
186
385
  return { value: items, next: i };
187
386
  }
188
387
 
189
388
  /**
190
- * Parse a frontmatter block (the inner YAML, no fences) into an object.
389
+ * Parse a frontmatter block (the inner YAML, no fences) into an object. The
390
+ * default is lenient: unreadable lines are skipped. `{ strict: true }` throws a
391
+ * SyntaxError on lines that are not `key: value`, orphaned indentation, and
392
+ * malformed quoted or inline values, so callers can report unreadable files.
191
393
  */
192
- export function parseFrontmatter(fm) {
394
+ export function parseFrontmatter(fm, { strict = false } = {}) {
193
395
  if (!fm) return {};
194
- // Drop the newline before the closing fence so it is not read as a blank line.
195
- return parseMap(fm.replace(/\n$/, '').split('\n'), 0, 0).value;
396
+ const previous = strictMode;
397
+ strictMode = strict;
398
+ try {
399
+ // Drop the newline before the closing fence so it is not read as a blank line.
400
+ return parseMap(fm.replace(/\n$/, '').split('\n'), 0, 0).value;
401
+ } finally {
402
+ strictMode = previous;
403
+ }
196
404
  }
197
405
 
198
406
  /** Parse a full document (with fences) into { data, body }. */
@@ -208,15 +416,22 @@ function serializeValue(v) {
208
416
  if (v === null) return 'null';
209
417
  if (typeof v === 'boolean' || typeof v === 'number') return String(v);
210
418
  if (Array.isArray(v)) return `[${v.map((x) => serializeValue(x)).join(', ')}]`;
419
+ // Nested objects inside an inline array go out as JSON, which the flow parser reads back.
420
+ if (typeof v === 'object') return JSON.stringify(v);
211
421
  const s = String(v);
212
- if (s === '' || /[:#\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s)) return JSON.stringify(s);
422
+ if (s === '' || /[:#,\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s) || /^[-?!&*%@`]/.test(s)) return JSON.stringify(s);
213
423
  // A string that would read back as a number, boolean or null stays quoted,
214
424
  // so "07003" and "true" survive a round trip (here and in YAML 1.1 readers).
215
425
  if (LOOKS_TYPED.test(s)) return JSON.stringify(s);
216
426
  return s;
217
427
  }
218
428
 
219
- function serializeEntry(lines, key, value, indent = 0) {
429
+ function serializeKey(key) {
430
+ return /^[A-Za-z0-9_][A-Za-z0-9_.\-/]*$/.test(key) ? key : JSON.stringify(key);
431
+ }
432
+
433
+ function serializeEntry(lines, rawKey, value, indent = 0) {
434
+ const key = serializeKey(rawKey);
220
435
  const pad = ' '.repeat(indent);
221
436
  if (Array.isArray(value)) {
222
437
  if (!value.length) {
@@ -233,10 +448,10 @@ function serializeEntry(lines, key, value, indent = 0) {
233
448
  }
234
449
  const [first, ...rest] = entries;
235
450
  if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
236
- lines.push(`${pad} - ${first[0]}:`);
451
+ lines.push(`${pad} - ${serializeKey(first[0])}:`);
237
452
  serializeObject(lines, first[1], indent + 6);
238
453
  } else {
239
- lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
454
+ lines.push(`${pad} - ${serializeKey(first[0])}: ${serializeValue(first[1])}`);
240
455
  }
241
456
  for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
242
457
  } else {
package/src/kernel.mjs CHANGED
@@ -184,6 +184,28 @@ export function createKernel(options = {}) {
184
184
  if (currentData?.type && currentData.type !== nextData.type) {
185
185
  return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
186
186
  }
187
+ // Replaying an identical full operation is a no-op, not a replace. This must
188
+ // be checked before the append-only guard so that re-importing a bundle that
189
+ // is already present reports `unchanged` instead of failing (spec §17.1).
190
+ if (current && current.bytes.toString('utf8') === nextContent) {
191
+ const noopAuth = await authorize({
192
+ principal, workspaceId: envelope.workspace_id, definition,
193
+ action: envelope.type === 'operation' ? 'replace' : actionFor(envelope, true, isAppendType(definition)),
194
+ context,
195
+ });
196
+ if (!noopAuth.allowed) {
197
+ const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
198
+ return failure(code, envelope, operationId, [`Access denied: ${noopAuth.reason}`], [{ field: 'principal', issue: noopAuth.reason }]);
199
+ }
200
+ const response = {
201
+ success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
202
+ committed_at: current.version?.committed_at || null, dry_run: false,
203
+ unchanged: true,
204
+ validation: { valid: true, type: definition.qualified_type, errors: [], warnings: [] },
205
+ };
206
+ try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); } catch {}
207
+ return response;
208
+ }
187
209
  // A full replace would rewrite recorded history (spec §5.3).
188
210
  if (current && isAppendType(definition)) {
189
211
  return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
package/src/registry.mjs CHANGED
@@ -297,26 +297,68 @@ export function resolvePrimitiveDefinition(registrySet, type) {
297
297
  return local ? registrySet.types.get(local) || null : null;
298
298
  }
299
299
 
300
+ /**
301
+ * Names of the extensions loaded when a caller does not scope the load.
302
+ *
303
+ * A registry directory accumulates one extension per product. Two unrelated
304
+ * products may each legitimately define a shared primitive name (e.g. both a
305
+ * CRM and a ticketing product needing `integration_connection`), and §5.6 makes
306
+ * that a load-time collision. `extensions/defaults.json` names the set that is
307
+ * safe to compose together; every other extension MUST be selected explicitly by
308
+ * the caller that owns it. Without the manifest, every extension is loaded and
309
+ * any cross-product collision stays a hard error rather than a silent preference.
310
+ */
311
+ function loadExtensionDefaults(registryDir) {
312
+ const manifest = path.join(registryDir, 'extensions', 'defaults.json');
313
+ if (!fs.existsSync(manifest)) return null;
314
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
315
+ const names = parsed?.default_extensions;
316
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
317
+ throw new Error('extensions/defaults.json: default_extensions must be an array of registry names.');
318
+ }
319
+ return names;
320
+ }
321
+
300
322
  /**
301
323
  * Load the core registry and merge extension registries.
324
+ *
325
+ * `only` scopes the load to a set of registry names (e.g. `['dabber']`). A
326
+ * registry directory may hold extensions for unrelated products that legitimately
327
+ * declare the same shared primitive name; loading them together is a load-time
328
+ * collision (§5.6), so a caller that knows which extension a vault belongs to
329
+ * MUST be able to load just that one. When `only` is omitted, the set named by
330
+ * `extensions/defaults.json` is loaded; failing that, every extension is loaded
331
+ * and any duplicate name remains a hard error rather than a silent preference.
332
+ *
302
333
  * @param {string} [registryDir] - directory containing core.json + extensions/
334
+ * @param {{ only?: string[]|Set<string> }} [options]
303
335
  * @returns {{ types: Map<string, object>, contractTypes: Set<string>, portabilityClasses: string[], core: object, extensions: Set<string> }}
304
336
  */
305
- export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR) {
337
+ export function loadRegistries(registryDir = DEFAULT_REGISTRY_DIR, options = {}) {
306
338
  const corePath = path.join(registryDir, 'core.json');
307
339
  const core = JSON.parse(fs.readFileSync(corePath, 'utf8'));
340
+ const requested = options.only ? new Set(options.only) : null;
341
+ const defaults = requested ? null : loadExtensionDefaults(registryDir);
342
+ const only = requested || (defaults ? new Set(defaults) : null);
308
343
  const extensionInputs = [];
309
344
  const extDir = path.join(registryDir, 'extensions');
310
345
  if (fs.existsSync(extDir)) {
311
- for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json')).sort()) {
346
+ for (const file of fs.readdirSync(extDir).filter((f) => f.endsWith('.json') && f !== 'defaults.json').sort()) {
312
347
  const extPath = path.join(extDir, file);
313
348
  if (fs.lstatSync(extPath).isSymbolicLink()) {
314
349
  throw new Error(`extensions/${file}: symlinked registries are not allowed.`);
315
350
  }
316
- extensionInputs.push({
317
- source: `extensions/${file}`,
318
- registry: JSON.parse(fs.readFileSync(extPath, 'utf8')),
319
- });
351
+ const registry = JSON.parse(fs.readFileSync(extPath, 'utf8'));
352
+ if (only && !only.has(registry.registry) && !only.has(file.replace(/\.json$/, ''))) continue;
353
+ extensionInputs.push({ source: `extensions/${file}`, registry });
354
+ }
355
+ }
356
+ if (only) {
357
+ const loaded = new Set(extensionInputs.map((e) => e.registry.registry));
358
+ for (const name of only) {
359
+ if (!loaded.has(name) && !extensionInputs.some((e) => e.source === `extensions/${name}.json`)) {
360
+ throw new Error(`registry '${name}' is not present in ${path.join(registryDir, 'extensions')}.`);
361
+ }
320
362
  }
321
363
  }
322
364
  return composeRegistries({ core, extensions: extensionInputs });