@gregiteen/ssss-cli 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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 +107 -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 +242 -53
- package/src/kernel.mjs +22 -0
- package/src/registry.mjs +48 -6
package/src/frontmatter.mjs
CHANGED
|
@@ -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,190 @@ export function splitFrontmatter(content) {
|
|
|
25
37
|
return { fm: inner, body: content.slice(sliceEnd), hasFm: true };
|
|
26
38
|
}
|
|
27
39
|
|
|
28
|
-
function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
80
|
+
try { return JSON.parse(tok); } catch { return unescapeDouble(tok.slice(1, -1)); }
|
|
34
81
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
41
|
-
if (
|
|
42
|
-
if (v
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
function coerce(raw) {
|
|
174
|
+
const v = raw.trim();
|
|
175
|
+
if (v === '') return '';
|
|
176
|
+
if (v[0] === '"' || v[0] === "'") {
|
|
177
|
+
const end = findClosingQuote(v);
|
|
178
|
+
if (end > 0 && /^\s*(#.*)?$/.test(v.slice(end + 1))) return unquote(v.slice(0, end + 1));
|
|
179
|
+
return v;
|
|
180
|
+
}
|
|
181
|
+
if (v[0] === '[' || v[0] === '{') {
|
|
182
|
+
try { return parseFlow(v); } catch { return v; }
|
|
183
|
+
}
|
|
184
|
+
return coercePlain(stripComment(v));
|
|
53
185
|
}
|
|
54
186
|
|
|
187
|
+
// A key is a bare word (no whitespace, `:` or `#`; inner single spaces allowed)
|
|
188
|
+
// or a quoted string, followed by `:` and then whitespace or end of line. The
|
|
189
|
+
// whitespace requirement keeps tags like `context:universal` and URLs as values.
|
|
190
|
+
const KEY_RE =
|
|
191
|
+
/^(?:([A-Za-z0-9_][^\s:#]*(?: [^\s:#]+)*)|("(?:[^"\\]|\\.)*")|('(?:[^']|'')*')):(?:[ \t]+(.*))?$/;
|
|
192
|
+
|
|
55
193
|
function splitKeyValue(text) {
|
|
56
|
-
const m = text.
|
|
57
|
-
|
|
194
|
+
const m = text.trimEnd().match(KEY_RE);
|
|
195
|
+
if (!m) return null;
|
|
196
|
+
const key = m[1] ?? unquote(m[2] ?? m[3]);
|
|
197
|
+
const rest = (m[4] ?? '').trim();
|
|
198
|
+
return { key, rest: rest.startsWith('#') ? '' : rest };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read a scalar or flow value that starts on `lines[i]` (already split into
|
|
203
|
+
* `rest`) and may wrap onto deeper-indented continuation lines. Plain and
|
|
204
|
+
* quoted lines fold with a space; a blank line is one newline.
|
|
205
|
+
*/
|
|
206
|
+
function readScalar(lines, i, keyIndent, rest) {
|
|
207
|
+
const inline = /^["'[{]/.test(rest);
|
|
208
|
+
let text = rest;
|
|
209
|
+
let j = i + 1;
|
|
210
|
+
let blanks = 0;
|
|
211
|
+
let last = i;
|
|
212
|
+
while (j < lines.length) {
|
|
213
|
+
const line = lines[j];
|
|
214
|
+
if (!line.trim()) { blanks++; j++; continue; }
|
|
215
|
+
if (countIndent(line) <= keyIndent) break;
|
|
216
|
+
if (!inline && line.trim().startsWith('#')) break;
|
|
217
|
+
const piece = inline ? line.trim() : stripComment(line.trim());
|
|
218
|
+
text += blanks ? '\n'.repeat(blanks) + piece : ` ${piece}`;
|
|
219
|
+
blanks = 0;
|
|
220
|
+
last = j;
|
|
221
|
+
j++;
|
|
222
|
+
}
|
|
223
|
+
return { value: coerce(text), next: last + 1 };
|
|
58
224
|
}
|
|
59
225
|
|
|
60
226
|
function nextSignificant(lines, start) {
|
|
@@ -64,7 +230,7 @@ function nextSignificant(lines, start) {
|
|
|
64
230
|
return -1;
|
|
65
231
|
}
|
|
66
232
|
|
|
67
|
-
const BLOCK_SCALAR_RE = /^([|>])([+-]?)
|
|
233
|
+
const BLOCK_SCALAR_RE = /^([|>])([+-]?)(?:\s+#.*)?$/;
|
|
68
234
|
|
|
69
235
|
/** Read a `|` (literal) or `>` (folded) block scalar with clip/strip/keep chomping. */
|
|
70
236
|
function parseBlockScalar(lines, start, parentIndent, style, chomp) {
|
|
@@ -84,23 +250,26 @@ function parseBlockScalar(lines, start, parentIndent, style, chomp) {
|
|
|
84
250
|
while (end > 0 && raw[end - 1] === '') end--;
|
|
85
251
|
const content = raw.slice(0, end);
|
|
86
252
|
let text = content[0] ?? '';
|
|
253
|
+
let prevText = 0; // index of the last non-blank line already emitted
|
|
87
254
|
for (let k = 1; k < content.length; k++) {
|
|
88
255
|
const line = content[k];
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
else if (
|
|
95
|
-
else
|
|
96
|
-
|
|
256
|
+
if (line === '') continue;
|
|
257
|
+
const blanks = k - prevText - 1;
|
|
258
|
+
if (style === '|') text += `${'\n'.repeat(blanks + 1)}${line}`;
|
|
259
|
+
// Folding: a break between two ordinary lines becomes a space, and blank
|
|
260
|
+
// lines each add one newline; a more-indented line on either side keeps its break.
|
|
261
|
+
else if (/^\s/.test(line) || /^\s/.test(content[prevText])) text += `${'\n'.repeat(blanks + 1)}${line}`;
|
|
262
|
+
else text += blanks ? `${'\n'.repeat(blanks)}${line}` : ` ${line}`;
|
|
263
|
+
prevText = k;
|
|
97
264
|
}
|
|
98
|
-
if (!content.length) text = '';
|
|
265
|
+
if (!content.length) text = chomp === '+' ? '\n'.repeat(raw.length) : '';
|
|
99
266
|
else if (chomp === '+') text += '\n'.repeat(1 + raw.length - end);
|
|
100
267
|
else if (chomp !== '-') text += '\n';
|
|
101
268
|
return { value: text, next: i };
|
|
102
269
|
}
|
|
103
270
|
|
|
271
|
+
const isDash = (text) => text === '-' || text.startsWith('- ');
|
|
272
|
+
|
|
104
273
|
function parseMap(lines, start, indent) {
|
|
105
274
|
const data = {};
|
|
106
275
|
let i = start;
|
|
@@ -120,19 +289,22 @@ function parseMap(lines, start, indent) {
|
|
|
120
289
|
continue;
|
|
121
290
|
}
|
|
122
291
|
if (kv.rest !== '') {
|
|
123
|
-
|
|
124
|
-
|
|
292
|
+
const scalar = readScalar(lines, i, indent, kv.rest);
|
|
293
|
+
data[kv.key] = scalar.value;
|
|
294
|
+
i = scalar.next;
|
|
125
295
|
continue;
|
|
126
296
|
}
|
|
127
297
|
|
|
128
298
|
const next = nextSignificant(lines, i + 1);
|
|
129
|
-
|
|
299
|
+
// YAML allows a block list at the same indent as its key.
|
|
300
|
+
const sameIndentList = next >= 0 && countIndent(lines[next]) === indent && isDash(lines[next].slice(indent));
|
|
301
|
+
if (next < 0 || (countIndent(lines[next]) <= indent && !sameIndentList)) {
|
|
130
302
|
data[kv.key] = {};
|
|
131
303
|
i++;
|
|
132
304
|
continue;
|
|
133
305
|
}
|
|
134
306
|
const childIndent = countIndent(lines[next]);
|
|
135
|
-
if (lines[next].slice(childIndent)
|
|
307
|
+
if (isDash(lines[next].slice(childIndent))) {
|
|
136
308
|
const parsed = parseList(lines, next, childIndent);
|
|
137
309
|
data[kv.key] = parsed.value;
|
|
138
310
|
i = parsed.next;
|
|
@@ -154,21 +326,25 @@ function parseList(lines, start, indent) {
|
|
|
154
326
|
const currentIndent = countIndent(line);
|
|
155
327
|
if (currentIndent < indent) break;
|
|
156
328
|
if (currentIndent > indent) { i++; continue; }
|
|
157
|
-
const item = line.slice(indent).match(
|
|
329
|
+
const item = line.slice(indent).match(/^-(?:(\s+)(.*))?$/);
|
|
158
330
|
if (!item) break;
|
|
159
|
-
const raw = item[
|
|
331
|
+
const raw = (item[2] ?? '').trim();
|
|
332
|
+
const keyIndent = indent + 1 + (item[1] ?? '').length;
|
|
160
333
|
const kv = splitKeyValue(raw);
|
|
161
334
|
if (kv) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
335
|
+
// Re-read the item as a map whose first key sits where the dash was, so
|
|
336
|
+
// nested children, block scalars and wrapped values of every key parse alike.
|
|
337
|
+
lines[i] = ' '.repeat(keyIndent) + raw;
|
|
338
|
+
const parsed = parseMap(lines, i, keyIndent);
|
|
339
|
+
items.push(parsed.value);
|
|
340
|
+
i = parsed.next;
|
|
341
|
+
} else if (isDash(raw)) {
|
|
342
|
+
// Nested sequence (`- - a`): re-read the inner dash as its own list item.
|
|
343
|
+
lines[i] = ' '.repeat(keyIndent) + raw;
|
|
344
|
+
const parsed = parseList(lines, i, keyIndent);
|
|
345
|
+
items.push(parsed.value);
|
|
346
|
+
i = parsed.next;
|
|
347
|
+
} else if (raw === '' || raw.startsWith('#')) {
|
|
172
348
|
const next = nextSignificant(lines, i + 1);
|
|
173
349
|
if (next >= 0 && countIndent(lines[next]) > indent) {
|
|
174
350
|
const parsed = parseMap(lines, next, countIndent(lines[next]));
|
|
@@ -178,9 +354,15 @@ function parseList(lines, start, indent) {
|
|
|
178
354
|
items.push('');
|
|
179
355
|
i++;
|
|
180
356
|
}
|
|
357
|
+
} else if (BLOCK_SCALAR_RE.test(raw)) {
|
|
358
|
+
const block = raw.match(BLOCK_SCALAR_RE);
|
|
359
|
+
const parsed = parseBlockScalar(lines, i + 1, indent, block[1], block[2]);
|
|
360
|
+
items.push(parsed.value);
|
|
361
|
+
i = parsed.next;
|
|
181
362
|
} else {
|
|
182
|
-
|
|
183
|
-
|
|
363
|
+
const scalar = readScalar(lines, i, indent, raw);
|
|
364
|
+
items.push(scalar.value);
|
|
365
|
+
i = scalar.next;
|
|
184
366
|
}
|
|
185
367
|
}
|
|
186
368
|
return { value: items, next: i };
|
|
@@ -208,15 +390,22 @@ function serializeValue(v) {
|
|
|
208
390
|
if (v === null) return 'null';
|
|
209
391
|
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
|
210
392
|
if (Array.isArray(v)) return `[${v.map((x) => serializeValue(x)).join(', ')}]`;
|
|
393
|
+
// Nested objects inside an inline array go out as JSON, which the flow parser reads back.
|
|
394
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
211
395
|
const s = String(v);
|
|
212
|
-
if (s === '' || /[
|
|
396
|
+
if (s === '' || /[:#,\[\]{}"'\\\n\r\t|>]/.test(s) || /^\s|\s$/.test(s) || /^[-?!&*%@`]/.test(s)) return JSON.stringify(s);
|
|
213
397
|
// A string that would read back as a number, boolean or null stays quoted,
|
|
214
398
|
// so "07003" and "true" survive a round trip (here and in YAML 1.1 readers).
|
|
215
399
|
if (LOOKS_TYPED.test(s)) return JSON.stringify(s);
|
|
216
400
|
return s;
|
|
217
401
|
}
|
|
218
402
|
|
|
219
|
-
function
|
|
403
|
+
function serializeKey(key) {
|
|
404
|
+
return /^[A-Za-z0-9_][A-Za-z0-9_.\-/]*$/.test(key) ? key : JSON.stringify(key);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function serializeEntry(lines, rawKey, value, indent = 0) {
|
|
408
|
+
const key = serializeKey(rawKey);
|
|
220
409
|
const pad = ' '.repeat(indent);
|
|
221
410
|
if (Array.isArray(value)) {
|
|
222
411
|
if (!value.length) {
|
|
@@ -233,10 +422,10 @@ function serializeEntry(lines, key, value, indent = 0) {
|
|
|
233
422
|
}
|
|
234
423
|
const [first, ...rest] = entries;
|
|
235
424
|
if (first[1] && typeof first[1] === 'object' && !Array.isArray(first[1])) {
|
|
236
|
-
lines.push(`${pad} - ${first[0]}:`);
|
|
425
|
+
lines.push(`${pad} - ${serializeKey(first[0])}:`);
|
|
237
426
|
serializeObject(lines, first[1], indent + 6);
|
|
238
427
|
} else {
|
|
239
|
-
lines.push(`${pad} - ${first[0]}: ${serializeValue(first[1])}`);
|
|
428
|
+
lines.push(`${pad} - ${serializeKey(first[0])}: ${serializeValue(first[1])}`);
|
|
240
429
|
}
|
|
241
430
|
for (const [childKey, childValue] of rest) serializeEntry(lines, childKey, childValue, indent + 4);
|
|
242
431
|
} 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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 });
|