@jarenjs/linq 0.49.2 → 0.66.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.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +227 -0
  2. package/README.md +650 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1221 -0
  5. package/docs/DB-CLIENT.md +882 -0
  6. package/docs/FLOW-PEN.md +1033 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +778 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1092 -0
  12. package/docs/QUERY-PEN.md +1724 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +251 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +255 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +377 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +48 -11
  24. package/src/contract/define.js +282 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +338 -0
  28. package/src/db/handle.js +89 -0
  29. package/src/db/include.js +351 -0
  30. package/src/db/index.js +24 -0
  31. package/src/db/ledger.js +195 -0
  32. package/src/db/live.js +43 -0
  33. package/src/db/membership.js +37 -0
  34. package/src/db/open.js +130 -0
  35. package/src/document.js +143 -13
  36. package/src/effect.js +65 -0
  37. package/src/errors.js +78 -6
  38. package/src/expression.js +463 -36
  39. package/src/federate.js +531 -0
  40. package/src/flow/capture.js +33 -0
  41. package/src/flow/dag.js +316 -0
  42. package/src/flow/fsm.js +323 -0
  43. package/src/flow/index.js +22 -0
  44. package/src/forms/index.js +43 -0
  45. package/src/forms/rules.js +170 -0
  46. package/src/forms/submit.js +177 -0
  47. package/src/index.js +5 -2
  48. package/src/jslt/body.js +226 -0
  49. package/src/jslt/index.js +18 -0
  50. package/src/jslt/rules.js +202 -0
  51. package/src/json-boundary.js +90 -0
  52. package/src/migration/define.js +318 -0
  53. package/src/migration/index.js +15 -0
  54. package/src/migration/steps.js +244 -0
  55. package/src/model/collection.js +273 -0
  56. package/src/model/define.js +125 -0
  57. package/src/model/entity.js +307 -0
  58. package/src/model/index.js +47 -0
  59. package/src/model/relation.js +85 -0
  60. package/src/provider.js +137 -20
  61. package/src/schema/brand.js +31 -0
  62. package/src/schema/builders.js +526 -0
  63. package/src/schema/check.js +29 -0
  64. package/src/schema/emit.js +394 -0
  65. package/src/schema/factories.js +239 -0
  66. package/src/schema/index.js +37 -0
  67. package/src/schema-of.js +24 -0
  68. package/src/sequence.js +233 -103
  69. package/src/sources.js +10 -3
  70. package/types/app.d.ts +293 -0
  71. package/types/contract.d.ts +468 -0
  72. package/types/db.d.ts +359 -0
  73. package/types/flow.d.ts +285 -0
  74. package/types/forms.d.ts +253 -0
  75. package/types/index.d.ts +296 -26
  76. package/types/jslt.d.ts +193 -0
  77. package/types/migration.d.ts +201 -0
  78. package/types/model.d.ts +526 -0
  79. package/types/schema.d.ts +494 -0
@@ -0,0 +1,43 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/linq/forms` — the form by code. The schema pen's every
4
+ * name, rebuilt from SUBCLASSES that carry the `x-form` vocabulary
5
+ * (`form({ visible, enabled, assert, computed, message })`), plus
6
+ * `assertOnSubmit()`, the one call that answers the same rules' Layer-3
7
+ * `$query` twin. A rule is an ANNOTATION: the document a form pen writes
8
+ * validates exactly as the schema pen's does, and `buildFormModel`,
9
+ * `compileFormRules` and `evaluateFormRules` read the rules off it.
10
+ * Nothing here imports `@jarenjs/forms`.
11
+ */
12
+
13
+ import {
14
+ SchemaBuilder, StringBuilder, NumberBuilder, ArrayBuilder, TupleBuilder,
15
+ ObjectBuilder, WhenBuilder, NeverBuilder,
16
+ } from '../schema/builders.js';
17
+ import { createFactories } from '../schema/factories.js';
18
+ import { withForm } from './rules.js';
19
+
20
+ /** The rule-aware classes: new classes, one mixin, no patched prototype. */
21
+ export const FormBuilder = withForm(SchemaBuilder);
22
+ export const FormStringBuilder = withForm(StringBuilder);
23
+ export const FormNumberBuilder = withForm(NumberBuilder);
24
+ export const FormArrayBuilder = withForm(ArrayBuilder);
25
+ export const FormTupleBuilder = withForm(TupleBuilder);
26
+ export const FormObjectBuilder = withForm(ObjectBuilder);
27
+ export const FormWhenBuilder = withForm(WhenBuilder);
28
+ export const FormNeverBuilder = withForm(NeverBuilder);
29
+
30
+ export const {
31
+ string, number, integer, boolean, nil, literal, enumOf,
32
+ object, array, tuple, record, union, discriminated, intersection,
33
+ named, ref, lazy, any, never, when, from, document,
34
+ datetime, date, time, duration,
35
+ } = /** @type {any} */ (createFactories({
36
+ Base: FormBuilder, String: FormStringBuilder, Number: FormNumberBuilder,
37
+ Array: FormArrayBuilder, Tuple: FormTupleBuilder, Object: FormObjectBuilder,
38
+ When: FormWhenBuilder, Never: FormNeverBuilder,
39
+ }));
40
+
41
+ export { withForm } from './rules.js';
42
+ export { assertOnSubmit } from './submit.js';
43
+ export { isSchemaBuilder, schemaOf, SCHEMA_BUILDER } from '../schema/brand.js';
@@ -0,0 +1,170 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The `x-form` vocabulary as one builder method: `withForm(Base)`,
4
+ * a mixin applied to every schema-pen class at module scope, so
5
+ * `./forms` is a set of NEW classes (never a patched prototype) on which
6
+ * any member can carry a cross-field rule.
7
+ *
8
+ * `form({ visible, enabled, assert, computed, message })` writes the one
9
+ * `x-form` annotation the forms README's layer 2 specifies. The three
10
+ * predicates and `computed` are CALLBACKS captured over the rule
11
+ * context, never paths typed as strings: `$` is the whole form document
12
+ * (cross-field is the point), and `value`/`pointer` are the two
13
+ * externals `compileFormRules` binds per evaluation — so the callback
14
+ * receives one context object spelling all three, `c.root`, `c.value`
15
+ * and `c.pointer`, and any other name is `JL0104` here rather than a
16
+ * compile error from forms naming the same two.
17
+ *
18
+ * What the pen refuses is what the reader would refuse and the builder
19
+ * can already see: a member `x-form` does not define, and `preview` —
20
+ * which is not an authored member at all but the format registry's own
21
+ * hint (`getFormatInfo(format).preview`), so spelling it here would
22
+ * write a keyword nothing reads.
23
+ */
24
+
25
+ import { LinqBuildError } from '../errors.js';
26
+ import { captureQuery } from '../capture-root.js';
27
+ import { describeValue, requireJson } from '../json-boundary.js';
28
+
29
+ /** The keyword every rule method writes. */
30
+ export const KEYWORD = 'x-form';
31
+
32
+ /** The members `x-form` defines, in the order the README's table lists them. */
33
+ const RULE_MEMBERS = Object.freeze(['visible', 'enabled', 'assert', 'computed', 'message']);
34
+
35
+ /** The members captured as queries over the rule context. */
36
+ const QUERY_MEMBERS = Object.freeze(['visible', 'enabled', 'assert', 'computed']);
37
+
38
+ /** The two externals `compileFormRules` binds, beside the document at `$`. */
39
+ const EXTERNALS = Object.freeze(['value', 'pointer']);
40
+
41
+ /** Where an unbound name could have come from, appended to `JL0104`. */
42
+ const ADVICE = () => ' — the document being edited is the context\'s root (c.root), the '
43
+ + 'field\'s own value c.value and its pointer c.pointer';
44
+
45
+ /**
46
+ * The rule context: one object over the capture's document root and its
47
+ * two externals. `root` is the value proxy (`$`), so `c.root.company`
48
+ * writes `"$.company"`; anything the evaluator does not bind falls
49
+ * through to the externals proxy, which refuses it by name.
50
+ * @param {any} doc - the `$` proxy
51
+ * @param {any} externals - the externals proxy (`value`, `pointer`)
52
+ * @returns {any}
53
+ */
54
+ function contextProxy(doc, externals) {
55
+ return new Proxy(Object.freeze({}), {
56
+ get(_target, prop) {
57
+ if (prop === 'root') return doc;
58
+ if (typeof prop === 'symbol') return undefined;
59
+ return externals[prop];
60
+ },
61
+ });
62
+ }
63
+
64
+ /**
65
+ * One query-valued rule member: a callback captured over the context, or
66
+ * a query document written by hand, copied.
67
+ * @param {string} member - `visible`, `enabled`, `assert` or `computed`
68
+ * @param {any} value
69
+ * @returns {any} the query document (plain JSON)
70
+ */
71
+ function ruleMember(member, value) {
72
+ if (typeof value !== 'function') {
73
+ return JSON.parse(JSON.stringify(requireJson(value, `form() ${member}`)));
74
+ }
75
+ return captureQuery(`form() ${member}`, EXTERNALS,
76
+ (doc, externals) => value(contextProxy(doc, externals)),
77
+ { advice: ADVICE, fold: false });
78
+ }
79
+
80
+ /**
81
+ * The `message` member: a plain string (an inline template) or a
82
+ * MessageSpec object, verbatim.
83
+ * @param {any} value
84
+ * @returns {any}
85
+ */
86
+ function readMessage(value) {
87
+ if (typeof value === 'string') return value;
88
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
89
+ throw new LinqBuildError('JL0101',
90
+ 'form() message is an inline string or a MessageSpec { $msgid?, message?, params? }, '
91
+ + `got ${describeValue(value)}`, '/message');
92
+ }
93
+ if (value.$msgid === undefined && value.message === undefined) {
94
+ throw new LinqBuildError('JL0101',
95
+ "form() message as a MessageSpec needs '$msgid' and/or 'message'", '/message');
96
+ }
97
+ return JSON.parse(JSON.stringify(requireJson(value, 'form() message')));
98
+ }
99
+
100
+ /**
101
+ * The mixin: a subclass of `Base` carrying `form()`.
102
+ * @template {new (state: any) => any} B
103
+ * @param {B} Base - a schema-pen builder class
104
+ * @returns {B} a new class, `Base` plus the vocabulary
105
+ */
106
+ export function withForm(Base) {
107
+ return class extends Base {
108
+ /**
109
+ * One `x-form` annotation (the forms README, layer 2): cross-field
110
+ * visibility, enablement, a preemptive assertion, a derived value
111
+ * and the message an assertion failure renders. A rule is an
112
+ * ANNOTATION — it never changes what the schema validates, so
113
+ * `additionalProperties` and every other keyword stay exactly what
114
+ * the schema pen wrote.
115
+ *
116
+ * @param {{ visible?: any, enabled?: any, assert?: any, computed?: any, message?: any }} spec
117
+ * @returns {this}
118
+ * @throws {LinqBuildError} `JL0101` a member `x-form` does not define,
119
+ * or a message that is neither a string nor a MessageSpec;
120
+ * `JL0102` `preview`, which the format registry derives from the
121
+ * field's own `format`; `JL0104` a name the rule context does not bind
122
+ * @example
123
+ * s.string().form({ visible: (c) => c.root.company.ne('') });
124
+ * s.number().form({ computed: (c) => c.root.lines.all().amount.sum() });
125
+ */
126
+ form(spec) {
127
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
128
+ throw new LinqBuildError('JL0101',
129
+ 'form() takes { visible?, enabled?, assert?, computed?, message? }, got '
130
+ + describeValue(spec));
131
+ }
132
+ for (const key of Object.keys(spec)) {
133
+ if (key === 'preview') {
134
+ throw new LinqBuildError('JL0102',
135
+ "form() cannot write 'preview' — a field's preview hint is DERIVED from its "
136
+ + 'format by the registry (getFormatInfo(format).preview), never authored, and '
137
+ + 'buildFormModel reads it from there; spell the format instead, and a host '
138
+ + 'that understands the hint draws it beside the control', '/preview');
139
+ }
140
+ if (!RULE_MEMBERS.includes(key)) {
141
+ throw new LinqBuildError('JL0101',
142
+ `form() does not take '${key}' — x-form defines ${RULE_MEMBERS.join(', ')}`,
143
+ `/${key}`);
144
+ }
145
+ }
146
+ const current = this.annotation(KEYWORD) ?? {};
147
+ const rules = { ...current };
148
+ for (const member of QUERY_MEMBERS) {
149
+ if (spec[member] !== undefined) rules[member] = ruleMember(member, spec[member]);
150
+ }
151
+ if (spec.message !== undefined) rules.message = readMessage(spec.message);
152
+ // the README's own member order, whatever order the caller wrote
153
+ const ordered = {};
154
+ for (const member of RULE_MEMBERS) {
155
+ if (rules[member] !== undefined) ordered[member] = rules[member];
156
+ }
157
+ return this.annotate(KEYWORD, Object.freeze(ordered));
158
+ }
159
+
160
+ /** As in the schema pen, but `x-form` is owned here. @param {Record<string, any>} annotations */
161
+ meta(annotations) {
162
+ if (annotations !== null && typeof annotations === 'object' && KEYWORD in annotations) {
163
+ throw new LinqBuildError('JL0104',
164
+ `meta() cannot write '${KEYWORD}' — the forms pen owns that keyword; spell it `
165
+ + 'through form()');
166
+ }
167
+ return super.meta(annotations);
168
+ }
169
+ };
170
+ }
@@ -0,0 +1,177 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `assertOnSubmit()` — the second spelling of a rule written once.
4
+ * An `x-form.assert` is evaluated per keystroke by `evaluateFormRules`;
5
+ * the same constraint is authoritative at submit through the validator's
6
+ * `$query` keyword, and the forms README's layer 3 is the transform that
7
+ * copies one into the other. The pen offers it as ONE call over the
8
+ * document it wrote, so an author spells the rule once and asks for both
9
+ * documents.
10
+ *
11
+ * The rules the copy keeps are the README's, and each is a place a naive
12
+ * copy went wrong: an absent field binds `null` (through `$default`), an
13
+ * item-template assert quantifies over the ELEMENTS rather than the
14
+ * selected leaves, and an assert on a field that also declares `visible`
15
+ * is guarded by it so it holds vacuously while the field is hidden. The
16
+ * branches land on the ROOT, where `$` is the instance root the rule
17
+ * context expects; the message travels with them so submit renders the
18
+ * same text the keystroke path does.
19
+ *
20
+ * Nothing here imports `@jarenjs/forms`: the transform reads the emitted
21
+ * document, and the pen test pins it equal to
22
+ * `formRulesToQueryAssertions` over the corpus.
23
+ */
24
+
25
+ import { deepFreeze } from '@jarenjs/core/object';
26
+
27
+ import { LinqBuildError } from '../errors.js';
28
+ import { isSchemaBuilder, schemaOf } from '../schema/brand.js';
29
+ import { describeValue } from '../json-boundary.js';
30
+ import { KEYWORD } from './rules.js';
31
+
32
+ /**
33
+ * The loop-variable prefix for element quantification — the same one the
34
+ * forms transform uses, so the two documents are byte-equal.
35
+ */
36
+ const ITEM_VAR = '_item';
37
+
38
+ /** RFC 6901: `~` and `/` escape inside a pointer segment. @param {string} key */
39
+ const escapePointer = (key) => key.replaceAll('~', '~0').replaceAll('/', '~1');
40
+
41
+ /**
42
+ * The RFC 9535 name selector `['…']` for one member name.
43
+ * @param {string} key
44
+ * @returns {string}
45
+ */
46
+ function nameSelector(key) {
47
+ let out = "['";
48
+ for (const ch of key) {
49
+ const code = /** @type {number} */ (ch.codePointAt(0));
50
+ if (ch === '\\' || ch === "'") out += `\\${ch}`;
51
+ else if (code < 0x20) out += `\\u${code.toString(16).padStart(4, '0')}`;
52
+ else out += ch;
53
+ }
54
+ return `${out}']`;
55
+ }
56
+
57
+ /** A new chunk list with `selector` appended to its last chunk. */
58
+ function extendChunks(chunks, selector) {
59
+ const next = chunks.slice();
60
+ next[next.length - 1] += selector;
61
+ return next;
62
+ }
63
+
64
+ /**
65
+ * The `$query` document for one assert, from its location expressed as
66
+ * path chunks split at each array expansion.
67
+ * @param {readonly string[]} chunks
68
+ * @param {any} assert - the authored rule document
69
+ * @param {string} pointer - the field's data pointer
70
+ * @param {any} [visible] - the field's `visible` rule, when it has one
71
+ * @returns {any}
72
+ */
73
+ function assertQuery(chunks, assert, pointer, visible) {
74
+ const depth = chunks.length - 1;
75
+ const at = (k) => (k === 0 ? chunks[0] : `$${ITEM_VAR}${k - 1}${chunks[k]}`);
76
+ const body = visible === undefined ? assert : { $or: [{ $not: visible }, assert] };
77
+ let query = {
78
+ $let: { value: { $default: [at(depth), { $const: null }] }, pointer: { $const: pointer } },
79
+ $return: body,
80
+ };
81
+ for (let k = depth - 1; k >= 0; k--) {
82
+ query = { $every: { [`${ITEM_VAR}${k}`]: at(k) }, $satisfies: query };
83
+ }
84
+ return query;
85
+ }
86
+
87
+ /**
88
+ * The `errorMessage.$query` spec one assert carries: the rule's message
89
+ * with `params` merged over `{ pointer }`, or the catalog default.
90
+ * @param {any} message
91
+ * @param {string} pointer
92
+ * @returns {any}
93
+ */
94
+ function messageSpec(message, pointer) {
95
+ if (typeof message === 'string') return { message, params: { pointer } };
96
+ if (message !== null && typeof message === 'object' && !Array.isArray(message)) {
97
+ return { ...message, params: { pointer, ...(message.params || {}) } };
98
+ }
99
+ return { $msgid: 'x-form/assert', params: { pointer } };
100
+ }
101
+
102
+ /**
103
+ * Walk the structural spine `buildFormModel` walks — `properties`,
104
+ * `prefixItems`, `items`, `allOf` — collecting every `x-form.assert`
105
+ * with its data location. `$ref`s are NOT resolved: a definition's data
106
+ * location depends on its use site.
107
+ * @param {any} schema
108
+ * @param {string} pointer
109
+ * @param {readonly string[]} chunks
110
+ * @param {any[]} out
111
+ */
112
+ function collect(schema, pointer, chunks, out) {
113
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) return;
114
+
115
+ const rules = schema[KEYWORD];
116
+ if (rules !== null && typeof rules === 'object' && !Array.isArray(rules)
117
+ && rules.assert !== undefined) {
118
+ out.push({
119
+ query: assertQuery(chunks, rules.assert, pointer, rules.visible),
120
+ pointer,
121
+ message: rules.message,
122
+ });
123
+ }
124
+ if (schema.properties !== null && typeof schema.properties === 'object') {
125
+ for (const [key, sub] of Object.entries(schema.properties)) {
126
+ collect(sub, `${pointer}/${escapePointer(key)}`, extendChunks(chunks, nameSelector(key)), out);
127
+ }
128
+ }
129
+ if (Array.isArray(schema.prefixItems)) {
130
+ for (let i = 0; i < schema.prefixItems.length; i++) {
131
+ collect(schema.prefixItems[i], `${pointer}/${i}`, extendChunks(chunks, `[${i}]`), out);
132
+ }
133
+ }
134
+ if (schema.items !== null && typeof schema.items === 'object' && !Array.isArray(schema.items)) {
135
+ collect(schema.items, `${pointer}/-`, [...extendChunks(chunks, '[*]'), ''], out);
136
+ }
137
+ if (Array.isArray(schema.allOf)) {
138
+ for (const branch of schema.allOf) collect(branch, pointer, chunks, out);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The submit twin of a document's `x-form.assert` rules: every assert
144
+ * copied onto the ROOT as its own `allOf` branch
145
+ * `{ $query, errorMessage }`, so the rule an author wrote once for
146
+ * per-keystroke feedback is also what the compiled validator enforces.
147
+ *
148
+ * A document with no assert answers the document itself — there is
149
+ * nothing to copy, and a needless `allOf` would be a second spelling of
150
+ * the same schema.
151
+ *
152
+ * @param {any} root - the root builder, or a document
153
+ * @returns {any} the deep-frozen submit document
154
+ * @throws {LinqBuildError} `JL0101` a value that is not a builder or an
155
+ * object schema
156
+ * @example
157
+ * const schema = s.object({ vatId: s.string().form({ assert: …, message: … }) });
158
+ * new JarenValidator().compile(assertOnSubmit(schema)); // app-side
159
+ */
160
+ export function assertOnSubmit(root) {
161
+ const schema = isSchemaBuilder(root) ? schemaOf(root) : root;
162
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
163
+ throw new LinqBuildError('JL0101',
164
+ 'assertOnSubmit() takes the document\'s root builder or its schema object, got '
165
+ + describeValue(root));
166
+ }
167
+ /** @type {any[]} */
168
+ const asserts = [];
169
+ collect(schema, '', ['$'], asserts);
170
+ if (asserts.length === 0) return deepFreeze(JSON.parse(JSON.stringify(schema)));
171
+ const branches = asserts.map((entry) => ({
172
+ $query: entry.query,
173
+ errorMessage: { $query: messageSpec(entry.message, entry.pointer) },
174
+ }));
175
+ const allOf = Array.isArray(schema.allOf) ? schema.allOf : [];
176
+ return deepFreeze(JSON.parse(JSON.stringify({ ...schema, allOf: [...allOf, ...branches] })));
177
+ }
package/src/index.js CHANGED
@@ -4,11 +4,14 @@
4
4
  * expressions as plain Jaren query documents (QUERY-FORMAT.md),
5
5
  * executes deferred over any iterable, and hands the SAME document
6
6
  * whole to any provider exposing `execute(document, options)` (D2:
7
- * contract-level coupling, never an import edge). The normative
8
- * surface, mapping table and error codes live in docs/LINQ-FORMAT.md.
7
+ * contract-level coupling the chain imports no provider; the client
8
+ * subpath `./db` is the package's one declared edge, toward the
9
+ * store). The normative surface, mapping table and error codes live
10
+ * in docs/QUERY-PEN.md.
9
11
  */
10
12
 
11
13
  export { from, fromDocument, Sequence } from './sequence.js';
12
14
  export { fromAsync, AsyncSequence } from './async.js';
13
15
  export { createPushQueue } from './sources.js';
16
+ export { federate } from './federate.js';
14
17
  export { LinqBuildError, LinqRuntimeError, LINQ_CODES } from './errors.js';
@@ -0,0 +1,226 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `body()` — a JSLT rule body captured through the chain's own
4
+ * recording proxy over the shared root capture: `fn(value, x)` with
5
+ * `value` rooted at `$` (the matched value) and `x` the externals —
6
+ * `root` and `path`, which the engine binds on every dispatch
7
+ * (JSLT-FORMAT §8.2), plus the parameters the body declares (§8.1); an
8
+ * undeclared name is `JL0104` at build time, where the fix can be
9
+ * named. `apply()` spells the one body-local operator (§6) and `op()`
10
+ * any registered one (§13): both lift a hand-spelled operator into the
11
+ * capture, and the engine's compiler stays the only judge of what it
12
+ * means. A returned literal is spelled as the format's own constructor
13
+ * (`{ "level": "unknown" }`), never folded into `$const` as the chain
14
+ * spells a constant; a string starting `$` is escaped `$$`. The one
15
+ * rule the pen checks itself is the `[]` idiom of §6.3 —
16
+ * an `apply` as a bare object member is refused HERE because the
17
+ * engine would only refuse it at run time, on the second child.
18
+ */
19
+
20
+ import { liftExpression, toExpression } from '../expression.js';
21
+ import { captureQuery } from '../capture-root.js';
22
+ import { describeValue } from '../json-boundary.js';
23
+ import { LinqBuildError } from '../errors.js';
24
+ import { deepFreeze } from '@jarenjs/core/object';
25
+
26
+ /** The two names the engine binds on every dispatch (§8.2). */
27
+ const RESERVED = ['root', 'path'];
28
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
29
+
30
+ /** The `{ $apply: … }` NODES `apply()` built, by identity. The node, not
31
+ * the proxy over it: a proxy is consumed the moment its value is lowered
32
+ * (into a member, an operand, an argument), while the node it carries is
33
+ * the very object that lands in the emitted document. */
34
+ const APPLY_NODES = new WeakSet();
35
+ /** How many `body()` captures are in progress (captures nest). */
36
+ let bodies = 0;
37
+
38
+ /**
39
+ * The declared parameter names, or `JL0101`/`JL0104` naming the problem.
40
+ * @param {any} options
41
+ * @returns {string[]}
42
+ */
43
+ function readExternals(options) {
44
+ if (options === undefined) return [];
45
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
46
+ throw new LinqBuildError('JL0101',
47
+ `body() options are { externals?: string[] }, got ${describeValue(options)}`);
48
+ }
49
+ for (const key of Object.keys(options)) {
50
+ if (key !== 'externals') throw new LinqBuildError('JL0101', `body() does not take '${key}'`);
51
+ }
52
+ const list = options.externals;
53
+ if (list === undefined) return [];
54
+ if (!Array.isArray(list)) {
55
+ throw new LinqBuildError('JL0101',
56
+ `body() externals is an array of parameter names, got ${describeValue(list)}`);
57
+ }
58
+ /** @type {string[]} */
59
+ const names = [];
60
+ for (const name of list) {
61
+ if (typeof name !== 'string' || !NAME_RE.test(name)) {
62
+ throw new LinqBuildError('JL0101',
63
+ `body() externals are identifiers ('rate'), got ${describeValue(name)}`);
64
+ }
65
+ if (RESERVED.includes(name)) {
66
+ throw new LinqBuildError('JL0104',
67
+ `'${name}' is engine-bound on every dispatch (JSLT-FORMAT §8.2) — it needs no `
68
+ + 'declaration and is always present on the externals argument');
69
+ }
70
+ if (!names.includes(name)) names.push(name);
71
+ }
72
+ return names;
73
+ }
74
+
75
+ /**
76
+ * `an object member takes exactly one value` — the `[]` idiom's refusal.
77
+ * @param {string} key @param {string} at
78
+ */
79
+ function bareApply(key, at) {
80
+ return new LinqBuildError('JL0102',
81
+ `an object member takes exactly one value — '${key}' holds an apply(), which yields `
82
+ + 'a SEQUENCE and fails at run time on the second child (JQ2001); wrap the apply in '
83
+ + `[] (JSLT-FORMAT §6.3: ${key}: [apply(…)])`, at);
84
+ }
85
+
86
+ /**
87
+ * The `[]` idiom (§6.3): an object member holds exactly one value, and
88
+ * an `$apply` yields a sequence — so an `apply()` in a member position
89
+ * is refused. Inside an array constructor it is the idiom itself (the
90
+ * brackets splice the sequence); as an operator's operand it is an
91
+ * ordinary expression (`apply(…).count()`); at the top of the body it is
92
+ * the body's own sequence.
93
+ *
94
+ * The walk runs over the CAPTURED DOCUMENT rather than over the value
95
+ * the callback returned, because a callback may hand an `apply()` to an
96
+ * operator (`op('$if', [f, { children: apply(…) }, null])`) and the
97
+ * object literal is lowered before the callback ever returns — the
98
+ * marker is gone, and only the node it left in the document is still
99
+ * there to find. The document is plain JSON at this point and the nodes
100
+ * are the same objects `apply()` built, so identity is the whole test.
101
+ *
102
+ * Member position is read the way `toExpression` writes it, three lines
103
+ * earlier: a map CONSTRUCTOR has no `$`-prefixed key (a data object that
104
+ * does is spelled `$map`, as pairs), and everything else with one is an
105
+ * operator or FLWOR phrase whose operands are expressions.
106
+ * @param {any} node - a node of the captured document
107
+ * @param {string} at - its JSON pointer, for `docPath`
108
+ */
109
+ function refuseBareApply(node, at) {
110
+ if (node === null || typeof node !== 'object') return;
111
+ if (Array.isArray(node)) {
112
+ for (let i = 0; i < node.length; i++) refuseBareApply(node[i], `${at}/${i}`);
113
+ return;
114
+ }
115
+ const keys = Object.keys(node);
116
+ if (!keys.some((k) => k.charCodeAt(0) === 0x24)) {
117
+ for (const key of keys) {
118
+ if (APPLY_NODES.has(node[key])) throw bareApply(key, `${at}/${key}`);
119
+ refuseBareApply(node[key], `${at}/${key}`);
120
+ }
121
+ return;
122
+ }
123
+ // `$map` is the one phrase whose operands ARE members: it spells a data
124
+ // object with `$`-prefixed keys as [key, value] pairs (`toExpression`).
125
+ if (keys.length === 1 && keys[0] === '$map' && Array.isArray(node.$map)) {
126
+ node.$map.forEach((pair, i) => {
127
+ if (!Array.isArray(pair) || pair.length !== 2) {
128
+ refuseBareApply(pair, `${at}/$map/${i}`);
129
+ return;
130
+ }
131
+ if (APPLY_NODES.has(pair[1])) {
132
+ throw bareApply(typeof pair[0] === 'string' ? pair[0] : String(i), `${at}/$map/${i}/1`);
133
+ }
134
+ refuseBareApply(pair[1], `${at}/$map/${i}/1`);
135
+ });
136
+ return;
137
+ }
138
+ for (const key of keys) refuseBareApply(node[key], `${at}/${key}`);
139
+ }
140
+
141
+ /**
142
+ * `{ $apply: selector }` or `{ $apply: [selector, mode] }` — the
143
+ * apply-templates operator (§6), inside a `body()` callback. The
144
+ * selector is an expression (`v.chapters.all()`), a JSONPath string
145
+ * taken verbatim (`'$.chapters[*]'`), or plain data (`[1, 2]` embeds as
146
+ * `$const`); the mode is a literal string naming the target mode (a
147
+ * mode is not an expression, §6.2). The result is an expression: it
148
+ * composes as one (`apply(…).count()`), and as an array element it is
149
+ * the `[]` idiom — as a bare object member it is refused (`JL0102`).
150
+ * @param {any} selector
151
+ * @param {string} [mode]
152
+ * @returns {any} an expression over the `$apply` document
153
+ */
154
+ export function apply(selector, mode = undefined) {
155
+ if (bodies === 0) {
156
+ throw new LinqBuildError('JL0102',
157
+ 'apply() spells $apply, which exists only inside a rule body (JSLT-FORMAT §6.1) — '
158
+ + 'call it inside body()');
159
+ }
160
+ if (selector === undefined) {
161
+ throw new LinqBuildError('JL0101',
162
+ "apply() takes a selector: a path (v.chapters.all(), '$.chapters[*]') or an expression");
163
+ }
164
+ const doc = typeof selector === 'string' ? selector : toExpression(selector);
165
+ let node;
166
+ if (mode === undefined) node = { $apply: doc };
167
+ else {
168
+ if (typeof mode !== 'string') {
169
+ throw new LinqBuildError('JL0101',
170
+ `apply() takes the target mode as a literal string (a mode is not an expression, `
171
+ + `JSLT-FORMAT §6.2), got ${describeValue(mode)}`);
172
+ }
173
+ node = { $apply: [doc, mode] };
174
+ }
175
+ APPLY_NODES.add(node);
176
+ return liftExpression(node);
177
+ }
178
+
179
+ /**
180
+ * `{ [name]: operands }` — a registered operator (§13), spelled without
181
+ * judging it: the pen writes the name it is given and the engine's
182
+ * compiler decides (`JQ0002` for a name no registry answers). Works in
183
+ * any capture in progress — a body, or a chain callback over an
184
+ * in-memory source compiled with the same registry.
185
+ * @param {string} name - the operator name, `$`-prefixed (`'$npv'`)
186
+ * @param {any} [operands] - one operand, or an array of them
187
+ * @returns {any} an expression over the operator document
188
+ */
189
+ export function op(name, operands = []) {
190
+ if (typeof name !== 'string' || name.length < 2 || name.charCodeAt(0) !== 0x24) {
191
+ throw new LinqBuildError('JL0101',
192
+ `op() takes an operator name starting with '$' ('$npv'), got ${describeValue(name)}`);
193
+ }
194
+ const value = Array.isArray(operands) ? operands.map(toExpression) : toExpression(operands);
195
+ return liftExpression({ [name]: value });
196
+ }
197
+
198
+ /**
199
+ * Capture one rule body: `fn(value, x)` over the matched value at `$`,
200
+ * with `x.root`, `x.path` and every declared parameter; the result is
201
+ * the body's query document, plain and deep-frozen.
202
+ * @param {(value: any, externals: any) => any} fn
203
+ * @param {{ externals?: readonly string[] }} [options]
204
+ * @returns {any} the body document (plain JSON)
205
+ */
206
+ export function body(fn, options = undefined) {
207
+ if (typeof fn !== 'function') {
208
+ throw new LinqBuildError('JL0101',
209
+ `body() takes a callback (value, x) => …, got ${describeValue(fn)}`);
210
+ }
211
+ const declared = readExternals(options);
212
+ const advice = (/** @type {string} */ name) =>
213
+ ` — a stylesheet parameter is declared first: body(fn, { externals: ['${name}'] })`;
214
+ bodies++;
215
+ try {
216
+ const doc = captureQuery('body()', RESERVED.concat(declared), fn, { advice, fold: false });
217
+ refuseBareApply(doc, '');
218
+ // a body spells a literal as the format's own constructor (never a
219
+ // folded `$const`); the tree may still share a caller's array or
220
+ // object, so it is copied before it is frozen
221
+ return deepFreeze(JSON.parse(JSON.stringify(doc)));
222
+ }
223
+ finally {
224
+ bodies--;
225
+ }
226
+ }
@@ -0,0 +1,18 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/linq/jslt` — `$jslt` 0.1 stylesheets by code. Rule
4
+ * bodies are callbacks captured over `$` through the chain's own
5
+ * recording proxy, with `root`/`path` and the declared parameters as
6
+ * typed externals; `apply()` spells the apply-templates operator and
7
+ * `op()` any registered one; `rule()` and `stylesheet()` write the
8
+ * rule object and the envelope of JSLT-FORMAT §2. The document is the
9
+ * deliverable: plain, deep-frozen JSON that `compileJsltStylesheet`
10
+ * takes unchanged; nothing here imports an engine. `body()` is the
11
+ * migration pen's body-capture entry point too (a `jslt` step's body
12
+ * binds the same `root`/`path`); the flow and app pens capture through
13
+ * `captureQuery` directly, because their evaluators bind nothing and a
14
+ * body's two reserved names would be a promise neither engine keeps.
15
+ */
16
+
17
+ export { body, apply, op } from './body.js';
18
+ export { rule, stylesheet } from './rules.js';