@jarenjs/json 0.9.2 → 0.34.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.
Files changed (74) hide show
  1. package/ARCHITECTURE.md +86 -13
  2. package/README.md +248 -23
  3. package/dist/types/canonical.d.ts +37 -0
  4. package/dist/types/cow.d.ts +28 -0
  5. package/dist/types/errors.d.ts +45 -0
  6. package/dist/types/index.d.ts +3 -0
  7. package/dist/types/jslt/errors.d.ts +15 -8
  8. package/dist/types/jslt/index.d.ts +22 -0
  9. package/dist/types/jslt/packs/finance.d.ts +119 -0
  10. package/dist/types/jslt/packs/index.d.ts +310 -0
  11. package/dist/types/jslt/packs/math.d.ts +159 -0
  12. package/dist/types/jslt/packs/stats.d.ts +48 -0
  13. package/dist/types/jslt/registry.d.ts +65 -0
  14. package/dist/types/jtlt/errors.d.ts +3 -6
  15. package/dist/types/option-variants.d.ts +29 -0
  16. package/dist/types/patch.d.ts +214 -0
  17. package/dist/types/path.d.ts +139 -9
  18. package/dist/types/pointer.d.ts +100 -9
  19. package/dist/types/query/compile.d.ts +12 -0
  20. package/dist/types/query/errors.d.ts +72 -8
  21. package/dist/types/query/index.d.ts +317 -25
  22. package/dist/types/query/normalize.d.ts +24 -0
  23. package/dist/types/query/operators.d.ts +241 -1
  24. package/dist/types/query/runtime.d.ts +5 -8
  25. package/dist/types/query/types.d.ts +34 -0
  26. package/dist/types/segments.d.ts +31 -0
  27. package/dist/types/write.d.ts +204 -0
  28. package/dist/types/xquery/parse.d.ts +2 -3
  29. package/docs/JSLT-FORMAT.md +74 -3
  30. package/docs/JSLT-PRELUDE.md +1 -1
  31. package/docs/QUERY-FORMAT.md +695 -33
  32. package/package.json +18 -4
  33. package/schemas/geojson.draft-07.schema.json +323 -0
  34. package/schemas/geojson.jaren.schema.json +863 -0
  35. package/schemas/geojson.schema.json +172 -0
  36. package/schemas/jaren-jslt.authoring.schema.json +142 -0
  37. package/schemas/jaren-jslt.draft-07.schema.json +152 -11
  38. package/schemas/jaren-jslt.llm-profile.schema.json +782 -0
  39. package/schemas/jaren-jslt.schema.json +152 -11
  40. package/schemas/jaren-query.draft-07.schema.json +152 -11
  41. package/schemas/jaren-query.llm-profile.schema.json +619 -0
  42. package/schemas/jaren-query.schema.json +82 -15
  43. package/src/basic.js +1 -1
  44. package/src/canonical.js +170 -0
  45. package/src/cow.js +106 -0
  46. package/src/errors.js +68 -0
  47. package/src/index.js +3 -0
  48. package/src/jslt/dispatch.js +178 -28
  49. package/src/jslt/errors.js +19 -14
  50. package/src/jslt/index.js +37 -29
  51. package/src/jslt/packs/finance.js +49 -0
  52. package/src/jslt/packs/index.js +18 -0
  53. package/src/jslt/packs/math.js +46 -0
  54. package/src/jslt/packs/stats.js +65 -0
  55. package/src/jslt/registry.js +200 -0
  56. package/src/jslt/stylesheet.js +14 -23
  57. package/src/jtlt/desugar.js +2 -3
  58. package/src/jtlt/errors.js +6 -12
  59. package/src/jtlt/index.js +12 -29
  60. package/src/jtlt/template.js +9 -18
  61. package/src/option-variants.js +54 -0
  62. package/src/patch.js +1052 -0
  63. package/src/path.js +319 -52
  64. package/src/pointer.js +225 -44
  65. package/src/query/compile.js +790 -75
  66. package/src/query/errors.js +72 -12
  67. package/src/query/index.js +274 -42
  68. package/src/query/normalize.js +489 -78
  69. package/src/query/operators.js +620 -23
  70. package/src/query/runtime.js +5 -19
  71. package/src/query/types.js +213 -0
  72. package/src/segments.js +409 -64
  73. package/src/write.js +660 -0
  74. package/src/xquery/parse.js +37 -53
@@ -0,0 +1,65 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The statistics pack — aggregators core does not ship as
4
+ * operators. `sum`/`min`/`max`/`avg`/`count` are ALREADY core query
5
+ * operators and are NOT re-registered here; this pack adds the summaries
6
+ * they don't cover. The helpers are small pure functions defined in this
7
+ * file (their natural home — core carries no statistics module), each a
8
+ * deterministic function of its array argument.
9
+ */
10
+
11
+ /** @param {number[]} xs */
12
+ function mean(xs) {
13
+ if (xs.length === 0) return undefined;
14
+ let s = 0;
15
+ for (const x of xs) s += x;
16
+ return s / xs.length;
17
+ }
18
+
19
+ /** Sample variance (n−1). @param {number[]} xs */
20
+ function variance(xs) {
21
+ if (xs.length < 2) return undefined;
22
+ const m = mean(xs);
23
+ let s = 0;
24
+ for (const x of xs) s += (x - m) * (x - m);
25
+ return s / (xs.length - 1);
26
+ }
27
+
28
+ /** @param {number[]} xs */
29
+ function stddev(xs) {
30
+ const v = variance(xs);
31
+ return v === undefined ? undefined : Math.sqrt(v);
32
+ }
33
+
34
+ /** @param {number[]} xs */
35
+ function median(xs) {
36
+ if (xs.length === 0) return undefined;
37
+ const sorted = xs.slice().sort((a, b) => a - b);
38
+ const mid = sorted.length >> 1;
39
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
40
+ }
41
+
42
+ /** Linear-interpolation percentile; `p` in [0, 100]. @param {number[]} xs */
43
+ function percentile(xs, p) {
44
+ if (xs.length === 0) return undefined;
45
+ const sorted = xs.slice().sort((a, b) => a - b);
46
+ if (sorted.length === 1) return sorted[0];
47
+ const rank = (Math.max(0, Math.min(100, p)) / 100) * (sorted.length - 1);
48
+ const lo = Math.floor(rank);
49
+ const hi = Math.ceil(rank);
50
+ if (lo === hi) return sorted[lo];
51
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
52
+ }
53
+
54
+ const aggNum = (signature, fn) => ({ kind: 'agg', signature, result: 'number', fn, pushable: false });
55
+
56
+ export const statsPack = {
57
+ name: 'stats',
58
+ entries: {
59
+ $mean: aggNum(['seq<number>'], mean),
60
+ $median: aggNum(['seq<number>'], median),
61
+ $variance: aggNum(['seq<number>'], variance),
62
+ $stddev: aggNum(['seq<number>'], stddev),
63
+ $percentile: aggNum(['seq<number>', 'number'], percentile),
64
+ },
65
+ };
@@ -0,0 +1,200 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The JSLT operator/aggregator registry (Ring 1): the
4
+ * `@jarenjs/formats` + `JarenValidator.addFormats` experience for the
5
+ * query/JSLT vocabulary. A caller composes packs of pure functions
6
+ * (`@jarenjs/core` math, finance, statistics) into a registry and gets
7
+ * a compiler bound to them — the operators then work in JSLT, in the
8
+ * query engine, and in linq-over-memory (which compiles to query
9
+ * documents).
10
+ *
11
+ * The mechanism is the engine's existing `options.extensions` seam (the
12
+ * same one the JSLT layer uses internally for `$apply`) plus
13
+ * `options.functions` (the `$call` registry). This file only TRANSLATES
14
+ * plain-data pack entries into those two shapes and holds them in an
15
+ * immutable-by-copy builder — so a pack never imports the operator ABI,
16
+ * exactly the decoupling `@jarenjs/formats` has from `@jarenjs/validate`.
17
+ *
18
+ * Entry kinds: `op` (scalar `$`-operator over scalar
19
+ * operands), `agg` (an operator whose declared `seq` operands are folded
20
+ * to arrays before the call — the generalization of core `$sum`'s fold),
21
+ * and `fn` (a bare `$call` function, the low-level escape). The published
22
+ * closed vocabulary is unchanged: without a registry a document using a
23
+ * pack operator fails `JQ0002` exactly as before.
24
+ */
25
+
26
+ import { EMPTY, Seq, seqOf, firstItem } from '../query/runtime.js';
27
+ import { CARD_OPT, CARD_MANY, isReservedQueryName } from '../query/normalize.js';
28
+ import { JsonQueryRuntimeError } from '../query/errors.js';
29
+ import { compileJsonQuery } from '../query/index.js';
30
+ import { compileJsltStylesheet } from './index.js';
31
+
32
+ const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
33
+ const RESULT_OPT = () => CARD_OPT;
34
+ const RESULT_MANY = () => CARD_MANY;
35
+ const RT_NUMBER = () => 'number';
36
+
37
+ /** Normalize an operand-kind token to `'seq'` or `'scalar'`. */
38
+ function operandKind(token) {
39
+ return typeof token === 'string' && token.startsWith('seq') ? 'seq' : 'scalar';
40
+ }
41
+
42
+ /**
43
+ * Translate one pack entry into an `options.extensions` operator entry
44
+ * (the shape `query/operators.js` uses). The compile gathers each `seq`
45
+ * operand into a JS array (`EMPTY`→`[]`, a lone value→`[value]`, a
46
+ * `Seq`→its items) and reads each scalar operand as a single value
47
+ * (`EMPTY` propagates to an empty result), calls the pure function, and
48
+ * wraps the result — a scalar as one item, an array under a `seq` result
49
+ * as a `Seq`. A throwing function becomes the coded runtime error
50
+ * `JQ2010`, never a crash.
51
+ * @param {string} name
52
+ * @param {{ signature: string[], result: string, fn: Function }} entry
53
+ */
54
+ function toExtensionEntry(name, entry) {
55
+ const sig = entry.signature.map(operandKind);
56
+ const arity = sig.length;
57
+ const seqResult = operandKind(entry.result) === 'seq';
58
+ const fn = entry.fn;
59
+ const params = arity === 1
60
+ ? 'expr'
61
+ : { kinds: new Array(arity).fill('expr'), min: arity };
62
+ return {
63
+ params,
64
+ result: seqResult ? RESULT_MANY : RESULT_OPT,
65
+ resultType: RT_NUMBER,
66
+ compile: (gets, args, docPath) => (f) => {
67
+ const call = new Array(arity);
68
+ for (let i = 0; i < arity; i++) {
69
+ const v = gets[i](f);
70
+ if (sig[i] === 'seq') {
71
+ call[i] = v === EMPTY ? [] : v instanceof Seq ? v.items.slice() : [v];
72
+ }
73
+ else {
74
+ if (v === EMPTY) return EMPTY; // a missing scalar operand → empty result
75
+ call[i] = v instanceof Seq ? firstItem(v) : v;
76
+ }
77
+ }
78
+ let out;
79
+ try {
80
+ out = fn(...call);
81
+ }
82
+ catch (err) {
83
+ throw new JsonQueryRuntimeError('JQ2010',
84
+ `registered operator '${name}' failed: ${err?.message ?? String(err)}`,
85
+ docPath);
86
+ }
87
+ if (seqResult) {
88
+ if (!Array.isArray(out)) return out === undefined || out === null ? EMPTY : out;
89
+ return out.length === 0 ? EMPTY : seqOf(out);
90
+ }
91
+ return out === undefined ? EMPTY : out;
92
+ },
93
+ };
94
+ }
95
+
96
+ /** Validate a pack's shape (host programming error → TypeError). */
97
+ function validatePack(pack) {
98
+ if (pack === null || typeof pack !== 'object'
99
+ || typeof pack.name !== 'string' || pack.name === ''
100
+ || pack.entries === null || typeof pack.entries !== 'object') {
101
+ throw new TypeError('a pack must be { name: string, entries: object }');
102
+ }
103
+ for (const [name, entry] of Object.entries(pack.entries)) {
104
+ if (entry === null || typeof entry !== 'object' || typeof entry.fn !== 'function') {
105
+ throw new TypeError(`pack '${pack.name}' entry '${name}' must carry a function 'fn'`);
106
+ }
107
+ const kind = entry.kind ?? (entry.signature?.some((s) => operandKind(s) === 'seq') ? 'agg' : 'op');
108
+ if (kind !== 'fn') {
109
+ if (name.charCodeAt(0) !== 0x24) {
110
+ throw new TypeError(`pack '${pack.name}' operator '${name}' must start with '$'`);
111
+ }
112
+ if (!Array.isArray(entry.signature) || entry.signature.length === 0) {
113
+ throw new TypeError(`pack '${pack.name}' operator '${name}' needs a non-empty signature`);
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Build an immutable JSLT operator registry. `.use(pack)` returns a NEW
121
+ * registry with the pack merged (a value, not a mutable singleton, so
122
+ * the functional spirit of the compilers is preserved). A name that
123
+ * collides with the core vocabulary, or with an already-registered name,
124
+ * throws a `TypeError` at `.use()` time — a host programming error, never
125
+ * a `JQ` document error.
126
+ * @param {{ extensions: Record<string, any>, functions: Record<string, Function>,
127
+ * meta: Record<string, any>, packs: string[] }} [state]
128
+ */
129
+ export function createJsltRegistry(state) {
130
+ const base = state ?? { extensions: {}, functions: {}, meta: {}, packs: [] };
131
+
132
+ const use = (pack) => {
133
+ validatePack(pack);
134
+ const extensions = { ...base.extensions };
135
+ const functions = { ...base.functions };
136
+ const meta = { ...base.meta };
137
+ for (const [name, entry] of Object.entries(pack.entries)) {
138
+ if (hasOwn(extensions, name) || hasOwn(functions, name)) {
139
+ throw new TypeError(`operator '${name}' is already registered (pack '${pack.name}')`);
140
+ }
141
+ const kind = entry.kind
142
+ ?? (entry.signature?.some((s) => operandKind(s) === 'seq') ? 'agg' : 'op');
143
+ if (kind === 'fn') {
144
+ functions[name] = entry.fn;
145
+ }
146
+ else {
147
+ if (isReservedQueryName(name)) {
148
+ throw new TypeError(`operator '${name}' collides with the core vocabulary`);
149
+ }
150
+ extensions[name] = toExtensionEntry(name, entry);
151
+ }
152
+ meta[name] = {
153
+ pack: pack.name, kind,
154
+ signature: entry.signature ?? null, result: entry.result ?? null,
155
+ pushable: entry.pushable ?? false, fn: entry.fn,
156
+ };
157
+ }
158
+ return createJsltRegistry({
159
+ extensions, functions, meta, packs: [...base.packs, pack.name],
160
+ });
161
+ };
162
+
163
+ // frozen once per registry instance, so identity-based compile caching
164
+ // (jslt/index.js cachedTransform) hits across calls with the same
165
+ // registry; only a caller's OWN extensions/functions force a fresh
166
+ // merged object.
167
+ const frozenExtensions = Object.freeze({ ...base.extensions });
168
+ const frozenFunctions = Object.freeze({ ...base.functions });
169
+
170
+ /** The `{ extensions, functions }` a compile call needs, merged over a
171
+ * caller's own options — the stable frozen objects when the caller
172
+ * added none (the common case). */
173
+ const mergedOptions = (opts) => {
174
+ const userExt = opts?.extensions && Object.keys(opts.extensions).length > 0;
175
+ const userFns = opts?.functions && Object.keys(opts.functions).length > 0;
176
+ return {
177
+ ...opts,
178
+ extensions: userExt ? { ...opts.extensions, ...base.extensions } : frozenExtensions,
179
+ functions: userFns ? { ...opts.functions, ...base.functions } : frozenFunctions,
180
+ };
181
+ };
182
+
183
+ return Object.freeze({
184
+ use,
185
+ /** Every registered operator/function name (for docs, AI, errors). */
186
+ names: () => [...Object.keys(base.extensions), ...Object.keys(base.functions)],
187
+ /** The raw `{ extensions, functions }` for a manual compile call. */
188
+ toOptions: () => ({ extensions: { ...base.extensions }, functions: { ...base.functions } }),
189
+ /** The SQL-pushable subset, as `{ name -> meta }` (consumed by the db
190
+ * in Rings 2–3; here it is just the data). */
191
+ forSql: () => Object.fromEntries(
192
+ Object.entries(base.meta).filter(([, m]) => m.pushable !== false)),
193
+ /** The full registration metadata (Rings 2–3, tooling). */
194
+ describe: () => ({ ...base.meta }),
195
+ /** Compile a JSLT stylesheet bound to this registry. */
196
+ compile: (stylesheet, opts) => compileJsltStylesheet(stylesheet, mergedOptions(opts)),
197
+ /** Compile a bare query document bound to this registry (linq-over-memory). */
198
+ compileQuery: (document, opts) => compileJsonQuery(document, mergedOptions(opts)),
199
+ });
200
+ }
@@ -4,6 +4,9 @@
4
4
  // schema, and body closures are compiled by dispatch.js.
5
5
 
6
6
  import { JsltCompileError } from './errors.js';
7
+ import { failerFor } from '../errors.js';
8
+ import { encodeJSONPointerSegment } from '../pointer.js';
9
+ import { isJsonObject } from '@jarenjs/core/object';
7
10
 
8
11
  const hasOwn = Object.hasOwn;
9
12
  const ENVELOPE_KEYS = new Set(['$jslt', 'rules', 'unmatched', 'modes']);
@@ -11,40 +14,28 @@ const RULE_KEYS = new Set(['match', 'mode', 'priority', 'body']);
11
14
  const MATCH_KEYS = new Set(['path', 'schema']);
12
15
  const MODE_KEYS = new Set(['unmatched']);
13
16
 
14
- function isObject(value) {
15
- return typeof value === 'object' && value !== null && !Array.isArray(value);
16
- }
17
-
18
- function escapeToken(token) {
19
- if (token.indexOf('~') < 0 && token.indexOf('/') < 0)
20
- return token;
21
- return token.replace(/~/g, '~0').replace(/\//g, '~1');
22
- }
23
-
24
- function fail(code, message, docPath) {
25
- throw new JsltCompileError(code, message, docPath);
26
- }
17
+ const fail = failerFor(JsltCompileError);
27
18
 
28
19
  function isDisposition(value) {
29
20
  return value === 'share' || value === 'fresh' || value === 'error';
30
21
  }
31
22
 
32
23
  function normalizeModes(value, docPath) {
33
- if (!isObject(value))
24
+ if (!isJsonObject(value))
34
25
  fail('JT0001', "'modes' must be an object", docPath);
35
26
  const modes = new Map();
36
27
  const names = Object.keys(value);
37
28
  for (let i = 0; i < names.length; i++) {
38
29
  const name = names[i];
39
- const modePath = docPath + '/' + escapeToken(name);
30
+ const modePath = docPath + '/' + encodeJSONPointerSegment(name);
40
31
  const config = value[name];
41
- if (!isObject(config))
32
+ if (!isJsonObject(config))
42
33
  fail('JT0001', `mode '${name}' must be an object`, modePath);
43
34
  const keys = Object.keys(config);
44
35
  for (let j = 0; j < keys.length; j++) {
45
36
  if (!MODE_KEYS.has(keys[j]))
46
37
  fail('JT0001', `unknown mode member '${keys[j]}'`,
47
- modePath + '/' + escapeToken(keys[j]));
38
+ modePath + '/' + encodeJSONPointerSegment(keys[j]));
48
39
  }
49
40
  if (!hasOwn(config, 'unmatched'))
50
41
  fail('JT0001', `mode '${name}' requires 'unmatched'`, modePath + '/unmatched');
@@ -69,7 +60,7 @@ function normalizeMatch(value, matchPath) {
69
60
  schemaDocPath: '',
70
61
  });
71
62
  }
72
- if (!isObject(value))
63
+ if (!isJsonObject(value))
73
64
  fail('JT0003', "'match' must be a JSONPath string or an object", matchPath);
74
65
 
75
66
  const keys = Object.keys(value);
@@ -78,7 +69,7 @@ function normalizeMatch(value, matchPath) {
78
69
  for (let i = 0; i < keys.length; i++) {
79
70
  if (!MATCH_KEYS.has(keys[i]))
80
71
  fail('JT0003', `unknown match member '${keys[i]}'`,
81
- matchPath + '/' + escapeToken(keys[i]));
72
+ matchPath + '/' + encodeJSONPointerSegment(keys[i]));
82
73
  }
83
74
  const hasPath = hasOwn(value, 'path');
84
75
  const hasSchema = hasOwn(value, 'schema');
@@ -98,14 +89,14 @@ function normalizeMatch(value, matchPath) {
98
89
 
99
90
  function normalizeRule(value, index, rulesPath) {
100
91
  const rulePath = rulesPath + '/' + index;
101
- if (!isObject(value))
92
+ if (!isJsonObject(value))
102
93
  fail('JT0002', 'a stylesheet rule must be an object', rulePath);
103
94
 
104
95
  const keys = Object.keys(value);
105
96
  for (let i = 0; i < keys.length; i++) {
106
97
  if (!RULE_KEYS.has(keys[i]))
107
98
  fail('JT0002', `unknown rule member '${keys[i]}'`,
108
- rulePath + '/' + escapeToken(keys[i]));
99
+ rulePath + '/' + encodeJSONPointerSegment(keys[i]));
109
100
  }
110
101
  if (!hasOwn(value, 'body'))
111
102
  fail('JT0002', "a stylesheet rule requires 'body'", rulePath + '/body');
@@ -153,12 +144,12 @@ export function normalizeJsltStylesheet(doc) {
153
144
  sourceRules = doc;
154
145
  rulesPath = '';
155
146
  }
156
- else if (isObject(doc)) {
147
+ else if (isJsonObject(doc)) {
157
148
  const keys = Object.keys(doc);
158
149
  for (let i = 0; i < keys.length; i++) {
159
150
  if (!ENVELOPE_KEYS.has(keys[i]))
160
151
  fail('JT0001', `unknown stylesheet member '${keys[i]}'`,
161
- '/' + escapeToken(keys[i]));
152
+ '/' + encodeJSONPointerSegment(keys[i]));
162
153
  }
163
154
  if (!hasOwn(doc, '$jslt'))
164
155
  fail('JT0001', "the stylesheet envelope requires '$jslt'", '/$jslt');
@@ -20,13 +20,12 @@
20
20
  // pair unambiguously.
21
21
 
22
22
  import { JtltCompileError } from './errors.js';
23
+ import { failerFor } from '../errors.js';
23
24
 
24
25
  const DOLLAR = 0x24;
25
26
  const BUILTIN_PRIORITY = -1e308;
26
27
 
27
- function fail(code, message, docPath) {
28
- throw new JtltCompileError(code, message, docPath);
29
- }
28
+ const fail = failerFor(JtltCompileError);
30
29
 
31
30
  // Collect every statically declared `$apply` target mode inside a plain
32
31
  // JSON expression tree. `$apply` mode arguments are literal strings by
@@ -3,17 +3,15 @@
3
3
  // stable `code` and a `docPath`, an RFC 6901 JSON Pointer into the template
4
4
  // document. Wrapped JSLT/query errors are exposed through `cause`.
5
5
 
6
+ import { CodedDocPathError } from '../errors.js';
7
+
6
8
  /**
7
9
  * Error thrown when a JTLT template is rejected at compile time
8
10
  * (`TL0xxx` codes).
9
11
  */
10
- export class JtltCompileError extends Error {
12
+ export class JtltCompileError extends CodedDocPathError {
11
13
  constructor(code, message, docPath, cause = undefined) {
12
- super(`${code}: ${message} at ${docPath}`,
13
- cause === undefined ? undefined : { cause });
14
- this.name = 'JtltCompileError';
15
- this.code = code;
16
- this.docPath = docPath;
14
+ super('JtltCompileError', code, message, docPath, cause);
17
15
  }
18
16
  }
19
17
 
@@ -21,13 +19,9 @@ export class JtltCompileError extends Error {
21
19
  * Error thrown when rendering with a compiled JTLT template fails
22
20
  * (`TL2xxx` codes).
23
21
  */
24
- export class JtltRuntimeError extends Error {
22
+ export class JtltRuntimeError extends CodedDocPathError {
25
23
  constructor(code, message, docPath, cause = undefined) {
26
- super(`${code}: ${message} at ${docPath}`,
27
- cause === undefined ? undefined : { cause });
28
- this.name = 'JtltRuntimeError';
29
- this.code = code;
30
- this.docPath = docPath;
24
+ super('JtltRuntimeError', code, message, docPath, cause);
31
25
  }
32
26
  }
33
27
 
package/src/jtlt/index.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // reimplemented.
9
9
 
10
10
  import { deepFreezeCopy } from '../query/normalize.js';
11
+ import { createOptionVariantCache, identityOf } from '../option-variants.js';
11
12
  import {
12
13
  compileJsltStylesheet,
13
14
  JsltCompileError,
@@ -85,41 +86,23 @@ export function compileJtltStylesheet(doc, options = {}) {
85
86
  return render;
86
87
  }
87
88
 
88
- const TEMPLATE_CACHE = new WeakMap();
89
+ const TEMPLATE_CACHE = createOptionVariantCache();
89
90
 
90
91
  function cachedRender(template, options) {
91
- let record = TEMPLATE_CACHE.get(template);
92
- if (record === undefined) {
93
- record = {
94
- defaultRender: null,
95
- variants: null,
96
- };
97
- TEMPLATE_CACHE.set(template, record);
98
- }
99
-
92
+ // The FULL option tuple keys the cache. `compileJtltStylesheet`
93
+ // forwards its options to the JSLT compiler, which also honours
94
+ // `memo` and `pathFunctions` — the old two-field comparison dropped
95
+ // both, so a one-call render with `pathFunctions` compiled without
96
+ // them (a live cache-poisoning bug, fixed by keying on everything).
100
97
  const compileTypeTest = typeof options?.compileTypeTest === 'function'
101
98
  ? options.compileTypeTest
102
99
  : null;
103
100
  const maxDepth = options?.maxDepth === undefined ? 1024 : options.maxDepth;
104
- if (compileTypeTest === null && maxDepth === 1024) {
105
- if (record.defaultRender === null)
106
- record.defaultRender = compileJtltStylesheet(template);
107
- return record.defaultRender;
108
- }
109
-
110
- let variants = record.variants;
111
- if (variants === null) {
112
- variants = [];
113
- record.variants = variants;
114
- }
115
- for (let i = 0; i < variants.length; i++) {
116
- const variant = variants[i];
117
- if (variant.compileTypeTest === compileTypeTest && variant.maxDepth === maxDepth)
118
- return variant.render;
119
- }
120
- const render = compileJtltStylesheet(template, options);
121
- variants.push({ compileTypeTest, maxDepth, render });
122
- return render;
101
+ const memo = options?.memo === true;
102
+ const pathFunctions = options?.pathFunctions == null ? null : options.pathFunctions;
103
+ const key = `${identityOf(compileTypeTest)}|${maxDepth}|${memo ? 1 : 0}|${identityOf(pathFunctions)}`;
104
+ return TEMPLATE_CACHE.getOrCompile(template, key,
105
+ () => compileJtltStylesheet(template, options));
123
106
  }
124
107
 
125
108
  /**
@@ -6,6 +6,9 @@
6
6
  // drift apart.
7
7
 
8
8
  import { JtltCompileError } from './errors.js';
9
+ import { failerFor } from '../errors.js';
10
+ import { encodeJSONPointerSegment } from '../pointer.js';
11
+ import { isJsonObject } from '@jarenjs/core/object';
9
12
 
10
13
  const hasOwn = Object.hasOwn;
11
14
  const ENVELOPE_KEYS = new Set(['$jtlt', 'output', 'rules']);
@@ -16,30 +19,18 @@ const OUTPUT_METHODS = new Set(['text', 'xml']);
16
19
  // built-in rules, which sit at -1e308 - beneath every user rule.
17
20
  export const RESERVED_PRIORITY_FLOOR = -1e307;
18
21
 
19
- function isObject(value) {
20
- return typeof value === 'object' && value !== null && !Array.isArray(value);
21
- }
22
-
23
- function escapeToken(token) {
24
- if (token.indexOf('~') < 0 && token.indexOf('/') < 0)
25
- return token;
26
- return token.replace(/~/g, '~0').replace(/\//g, '~1');
27
- }
28
-
29
- function fail(code, message, docPath) {
30
- throw new JtltCompileError(code, message, docPath);
31
- }
22
+ const fail = failerFor(JtltCompileError);
32
23
 
33
24
  function normalizeRule(value, index, rulesPath) {
34
25
  const rulePath = rulesPath + '/' + index;
35
- if (!isObject(value))
26
+ if (!isJsonObject(value))
36
27
  fail('TL0002', 'a template rule must be an object', rulePath);
37
28
 
38
29
  const keys = Object.keys(value);
39
30
  for (let i = 0; i < keys.length; i++) {
40
31
  if (!RULE_KEYS.has(keys[i]))
41
32
  fail('TL0002', `unknown rule member '${keys[i]}'`,
42
- rulePath + '/' + escapeToken(keys[i]));
33
+ rulePath + '/' + encodeJSONPointerSegment(keys[i]));
43
34
  }
44
35
  if (!hasOwn(value, 'body'))
45
36
  fail('TL0002', "a template rule requires 'body'", rulePath + '/body');
@@ -56,7 +47,7 @@ function normalizeRule(value, index, rulesPath) {
56
47
  rulePath + '/priority');
57
48
  }
58
49
  const hasMatch = hasOwn(value, 'match');
59
- if (hasMatch && typeof value.match !== 'string' && !isObject(value.match))
50
+ if (hasMatch && typeof value.match !== 'string' && !isJsonObject(value.match))
60
51
  fail('TL0002', "'match' must be a JSONPath string or an object", rulePath + '/match');
61
52
 
62
53
  return Object.freeze({
@@ -87,12 +78,12 @@ export function normalizeJtltTemplate(doc) {
87
78
  sourceRules = doc;
88
79
  rulesPath = '';
89
80
  }
90
- else if (isObject(doc)) {
81
+ else if (isJsonObject(doc)) {
91
82
  const keys = Object.keys(doc);
92
83
  for (let i = 0; i < keys.length; i++) {
93
84
  if (!ENVELOPE_KEYS.has(keys[i]))
94
85
  fail('TL0001', `unknown template member '${keys[i]}'`,
95
- '/' + escapeToken(keys[i]));
86
+ '/' + encodeJSONPointerSegment(keys[i]));
96
87
  }
97
88
  if (!hasOwn(doc, '$jtlt'))
98
89
  fail('TL0001', "the template envelope requires '$jtlt'", '/$jtlt');
@@ -0,0 +1,54 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Per-document compilation-variant caching, shared by the JSLT
4
+ * and JTLT one-call entry points. A document compiles differently under
5
+ * different options, so the cache key is the FULL option tuple — every
6
+ * option that changes what compiles must be part of the derived key, or
7
+ * a second call with different options silently reuses the first
8
+ * compilation (the cache-poisoning bug the JTLT cache had before the
9
+ * health pass). Identity-compared option values (hook functions,
10
+ * function-extension registries) are interned to stable per-process ids
11
+ * so the key is a flat string and lookup is O(1) instead of a linear
12
+ * scan over an unbounded variants array.
13
+ */
14
+
15
+ import { createBoundedCache, createWeakCache } from '@jarenjs/core/cache';
16
+
17
+ /** @type {WeakMap<object, number>} */
18
+ const IDENTITY_IDS = new WeakMap();
19
+ let nextIdentityId = 1;
20
+
21
+ /**
22
+ * A stable per-process id for an identity-compared option value.
23
+ * `null`/`undefined` share id 0 ("absent").
24
+ * @param {any} value
25
+ * @returns {number}
26
+ */
27
+ export function identityOf(value) {
28
+ if (value == null) return 0;
29
+ let id = IDENTITY_IDS.get(value);
30
+ if (id === undefined) {
31
+ id = nextIdentityId++;
32
+ IDENTITY_IDS.set(value, id);
33
+ }
34
+ return id;
35
+ }
36
+
37
+ /**
38
+ * A two-axis compilation cache: documents by identity (weak, entries
39
+ * die with the document), variants per document by derived option key
40
+ * (bounded LRU — the old variants array grew without bound).
41
+ * @param {number} [limitPerDocument] - variant bound per document
42
+ * @returns {{ getOrCompile: (document: object, key: string, compile: () => any) => any }}
43
+ */
44
+ export function createOptionVariantCache(limitPerDocument = 16) {
45
+ const byDocument = createWeakCache();
46
+ const newVariants = () => createBoundedCache(limitPerDocument);
47
+ return {
48
+ getOrCompile(document, key, compile) {
49
+ const variants = /** @type {import('@jarenjs/core/cache').BoundedCache<string, any>} */ (
50
+ byDocument.getOrCreate(document, newVariants));
51
+ return variants.getOrCreate(key, compile);
52
+ },
53
+ };
54
+ }