@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
package/src/db/live.js ADDED
@@ -0,0 +1,43 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `live(source, options)`: a chain's document and bound externals —
4
+ * or a hand-written document — handed to the store's own registration
5
+ * (`store.live` for an entity-root chain, `collection.live` for a
6
+ * collection's), so the mode, the reason and the maintenance are the
7
+ * store's (LIVE-FORMAT §7): an entity document re-runs on invalidation,
8
+ * declared, and a store opened without capture refuses (`JD0050`)
9
+ * exactly as it does for a document. The chain's `params()` bindings are
10
+ * the externals, fixed at registration; `options.externals` adds to them.
11
+ */
12
+
13
+ /** Whether a source is a chain: a document and an explanation to give. */
14
+ const isChain = (source) => source !== null && typeof source === 'object'
15
+ && typeof source.toDocument === 'function' && typeof source.explain === 'function';
16
+
17
+ /**
18
+ * The document a source stands for, with the values it bound.
19
+ * @param {any} source - a chain (either surface) or a query document
20
+ * @returns {{ document: any, bindings: Record<string, unknown> }}
21
+ */
22
+ function documentOf(source) {
23
+ if (!isChain(source)) return { document: source, bindings: {} };
24
+ const explained = source.explain();
25
+ // a chain split by a host callback has no document; its own refusal
26
+ // (`JL0005`) names why
27
+ if (explained.document === undefined) source.toDocument();
28
+ return { document: explained.document, bindings: explained.bindings };
29
+ }
30
+
31
+ /**
32
+ * Register a live query through the store's own registration.
33
+ * @param {(document: any, options: any) => Promise<any>} register - the
34
+ * store's `live` (entity roots) or a collection's
35
+ * @param {any} source - a chain or a document
36
+ * @param {any} [options] - LIVE-FORMAT §7 options; `externals` merge over
37
+ * the chain's bindings
38
+ * @returns {Promise<any>} the store's live query
39
+ */
40
+ export function registerLive(register, source, options = {}) {
41
+ const { document, bindings } = documentOf(source);
42
+ return register(document, { ...options, externals: { ...bindings, ...(options.externals ?? {}) } });
43
+ }
@@ -0,0 +1,37 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `link`/`unlink` on a handle: the relation table decides. The
4
+ * member must be a many-to-many relation of the entity — `JL0107`
5
+ * otherwise, naming the kind it is or the members that are declared —
6
+ * and then the store's own `link`/`unlink` record the change
7
+ * (MODEL-FORMAT §11.7) for `saveChanges()` to write. The store would
8
+ * refuse the same members itself (`JD2003`); the client sees it earlier
9
+ * from the table it already reads, mirrored, never invented.
10
+ */
11
+
12
+ import { LinqBuildError } from '../errors.js';
13
+
14
+ /**
15
+ * The many-to-many relation entry a membership operation names.
16
+ * @param {Record<string, any>} relations - the handle's relation table
17
+ * @param {string} entityName
18
+ * @param {string} member
19
+ * @param {string} verb - `link` or `unlink`, for the message
20
+ * @returns {any} the relation entry
21
+ */
22
+ export function requireMembership(relations, entityName, member, verb) {
23
+ const entry = relations[member];
24
+ if (entry === undefined) {
25
+ const declared = Object.keys(relations).filter((name) => relations[name].kind === 'manyToMany');
26
+ throw new LinqBuildError('JL0107',
27
+ `'${member}' is not a relation member of '${entityName}' — ${verb}() attaches a many-to-many `
28
+ + `membership${declared.length === 0 ? `, and '${entityName}' declares none`
29
+ : ` (${declared.map((name) => `'${name}'`).join(', ')})`}`);
30
+ }
31
+ if (entry.kind !== 'manyToMany') {
32
+ throw new LinqBuildError('JL0107',
33
+ `'${member}' is a ${entry.kind} relation of '${entityName}' — ${verb}() attaches many-to-many `
34
+ + "memberships only; write the related entity's foreign key instead");
35
+ }
36
+ return entry;
37
+ }
package/src/db/open.js ADDED
@@ -0,0 +1,130 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `open(model, options)`: `openStore` with every option forwarded
4
+ * verbatim, plus `validator` — a `JarenValidator` wired as the store's
5
+ * `compileSchema`. The default validator reproduces the configuration
6
+ * MIGRATING-FROM-ZOD's recipe uses (`collectErrors`, the string and
7
+ * date-time formats registered), so `s.string().email()` asserts out
8
+ * of the box; a host that already has a `compileSchema` passes it and
9
+ * wins; `validator: null` opens the store unvalidated — the store's own
10
+ * declared downgrade (`capabilities.validated === false`), chosen by
11
+ * name, never by omission. The client is a frozen record of handles built ONCE at open
12
+ * from the names the store declares — no Proxy anywhere: an unknown
13
+ * name is `undefined`, and for a pen model a compile error.
14
+ */
15
+
16
+ import { openStore } from '@jarenjs/db';
17
+ import { JarenValidator } from '@jarenjs/validate';
18
+ import { stringFormats, dateTimeFormats } from '@jarenjs/formats';
19
+ import { setObjectMember } from '@jarenjs/core/object';
20
+
21
+ import { createEntityHandle, createCollectionHandle } from './handle.js';
22
+ import { registerLive } from './live.js';
23
+
24
+ /**
25
+ * The validator the client compiles entity and collection schemas with
26
+ * when none is given: every issue collected, formats asserting.
27
+ * @returns {JarenValidator}
28
+ */
29
+ export function defaultValidator() {
30
+ return new JarenValidator({ collectErrors: true })
31
+ .addFormats(stringFormats)
32
+ .addFormats(dateTimeFormats);
33
+ }
34
+
35
+ /**
36
+ * Open a store and front it.
37
+ * @param {any} model - a `$model` document: the model pen's, or JSON
38
+ * @param {any} options - `openStore`'s options (`driver` required), plus
39
+ * `validator?` (a `JarenValidator`, wired as `compileSchema` unless an
40
+ * explicit `compileSchema` is given; `null` for an unvalidated store)
41
+ * @returns {Promise<any>} the client
42
+ */
43
+ export async function open(model, options) {
44
+ if (options === null || typeof options !== 'object') {
45
+ throw new TypeError('open needs { driver } from @jarenjs/db/node, /bun or /wasm');
46
+ }
47
+ const { validator, ...storeOptions } = options;
48
+ if (validator !== undefined && validator !== null
49
+ && (typeof validator !== 'object' || typeof validator.compile !== 'function')) {
50
+ throw new TypeError('open: validator must be a JarenValidator (an object with compile(schema)), '
51
+ + 'or null for an unvalidated store');
52
+ }
53
+ if (storeOptions.compileSchema === undefined && validator !== null) {
54
+ const jaren = validator ?? defaultValidator();
55
+ storeOptions.compileSchema = (schema) => jaren.compile(schema);
56
+ }
57
+ const store = await openStore(model, storeOptions);
58
+
59
+ /**
60
+ * The typed handles over ONE store view — the root store, or the one
61
+ * a transaction callback received. Building them from the same two
62
+ * constructors is what keeps a transaction's `entities.X` the same
63
+ * surface, with the same inference, as the client's own.
64
+ * @param {any} over
65
+ */
66
+ const handlesOf = (over) => {
67
+ const entities = {};
68
+ for (const name of over.roots ?? []) {
69
+ setObjectMember(entities, name, createEntityHandle(over, name));
70
+ }
71
+ const collections = {};
72
+ for (const name of Object.keys(model.collections ?? {})) {
73
+ setObjectMember(collections, name, createCollectionHandle(over, name));
74
+ }
75
+ return { entities: Object.freeze(entities), collections: Object.freeze(collections) };
76
+ };
77
+
78
+ /**
79
+ * The client a transaction callback receives: the same shape as the
80
+ * root client, over the store that is INSIDE the transaction. Its
81
+ * handles run as the transaction's owner rather than waiting for a
82
+ * commit they are part of, and its `transaction` nests.
83
+ *
84
+ * Built per transaction, because the handles bind to the store view —
85
+ * and the whole point of a transaction's own unit of work is that two
86
+ * handlers do not share one.
87
+ * @param {any} tx - the store the callback received
88
+ */
89
+ const transactionClient = (tx) => {
90
+ /** @type {Record<string, any>} */
91
+ const inner = {
92
+ store: tx,
93
+ capabilities: tx.capabilities,
94
+ ...handlesOf(tx),
95
+ transaction: (fn) => tx.transaction((nested) => fn(transactionClient(nested))),
96
+ // the named-savepoint group (MODEL-FORMAT §5.2), forwarded as it
97
+ // is: partial rollback belongs to the transaction that owns the
98
+ // connection, so the root client deliberately has no twin
99
+ savepoints: tx.savepoints,
100
+ };
101
+ if (tx.saveChanges !== undefined) {
102
+ inner.saveChanges = () => tx.saveChanges();
103
+ inner.live = (source, liveOptions) => registerLive(tx.live, source, liveOptions);
104
+ }
105
+ return Object.freeze(inner);
106
+ };
107
+
108
+ /** @type {Record<string, any>} */
109
+ const client = {
110
+ store,
111
+ capabilities: store.capabilities,
112
+ ...handlesOf(store),
113
+ // A transaction gets its own unit of work by default: two handlers
114
+ // on one client then hold two records for the same entity key and
115
+ // neither can see the other's pending state. `unitOfWork: 'shared'`
116
+ // opts back into the store's, for a caller who staged changes
117
+ // outside the transaction and means to save them inside it.
118
+ transaction: (fn, transactionOptions) => store.transaction(
119
+ (tx) => fn(transactionClient(tx)),
120
+ { unitOfWork: 'own', ...transactionOptions }),
121
+ close: (closeOptions) => store.close(closeOptions),
122
+ };
123
+ // the unit of work and entity live queries exist exactly when the
124
+ // model declares entities — as on the store
125
+ if (store.saveChanges !== undefined) {
126
+ client.saveChanges = () => store.saveChanges();
127
+ client.live = (source, liveOptions) => registerLive(store.live, source, liveOptions);
128
+ }
129
+ return Object.freeze(client);
130
+ }
package/src/document.js CHANGED
@@ -21,24 +21,142 @@
21
21
  * not tell an absent clause from a null-valued one, and silently emitted
22
22
  * the document WITHOUT the clause. Every such query then returned its
23
23
  * unfiltered, unprojected source.
24
+ *
25
+ * An item is an item. The engine's `$for` unpacks an item that is an
26
+ * array into its members, one level (QUERY-FORMAT §6.2, D4) — the
27
+ * ergonomic default for a path like `$.tags`, and the wrong default for
28
+ * a chain, where an array-valued ROW (a CSV record, a pair) is one item
29
+ * the C# contract never splits. So every source a phrase iterates is
30
+ * bound through an ARRAY CONSTRUCTOR — `{ "$for": { "it": ["$[*]"] } }`
31
+ * — whose one array item is unpacked exactly once, into the rows as
32
+ * they are; a reseated phrase is packed the same way, because a `$for`
33
+ * over an inner phrase's result would unpack again. The one source left
34
+ * bare is a PROVIDER's own root (`'$.Post[*]'`): a stored document is an
35
+ * object, so D4 never applies there, and the bare root is the shape the
36
+ * provider's planner pushes. The streaming async surface keeps items by
37
+ * construction, so the two surfaces agree on every row shape.
24
38
  */
25
39
 
26
40
  import { LinqBuildError } from './errors.js';
27
41
 
42
+ /** The binding names the emitted documents own (QUERY-PEN §7): the
43
+ * item bindings, the accumulator and the group — and the relation-hop
44
+ * bindings `r1`, `r2`, … a capture allocates (expression.js). A
45
+ * parameter may shadow none of them. */
46
+ export const RESERVED_BINDINGS = Object.freeze(['it', 'it2', 'acc', 'g']);
47
+ const HOP_BINDING_RE = /^r[1-9][0-9]*$/;
48
+
49
+ /**
50
+ * Whether a parameter name collides with a binding the document owns.
51
+ * @param {string} name
52
+ * @returns {boolean}
53
+ */
54
+ export function isReservedBinding(name) {
55
+ return RESERVED_BINDINGS.includes(name) || HOP_BINDING_RE.test(name);
56
+ }
57
+
58
+ /** The reserved names, for a refusal's message. */
59
+ export const RESERVED_BINDINGS_TEXT = 'it, it2, acc, g, and r1, r2, … for relation hops';
60
+
61
+ /** The member a `groupBy`'s items carry the group's rows in. Spelled
62
+ * once: the emitter writes it here and `expression.js` gives that member
63
+ * its fan, so `g.items.count()` counts the ROWS the way a group-join's
64
+ * `g.count()` already does. */
65
+ export const GROUP_ITEMS = 'items';
66
+
67
+ /** The stage kinds after which the items are no longer the source's
68
+ * rows: a relation name on them is an ordinary member (QUERY-PEN §3).
69
+ * The async surface adds its host boundary, `mapAsync`. */
70
+ export const PROJECTING_STAGES = new Set(['select', 'groupBy', 'join', 'groupJoin', 'aggregate']);
71
+
28
72
  /** The fixed clause order a phrase may fill left-to-right. */
29
- const SLOT_ORDER = ['where', 'groupby', 'orderby', 'return'];
73
+ const SLOT_ORDER = ['bindings', 'where', 'groupby', 'orderby', 'return'];
30
74
 
31
75
  /** The "this clause slot is unfilled" sentinel: a fresh object, so no
32
76
  * value a caller can express is ever mistaken for it. */
33
77
  const EMPTY = Symbol('linq.emptySlot');
34
78
 
35
- /** An open FLWOR phrase under construction. */
36
- function openPhrase(source) {
79
+ /**
80
+ * An open FLWOR phrase under construction. `bare` marks a source the
81
+ * phrase iterates WITHOUT packing (a provider's root); every other
82
+ * source is packed so an array item stays one item.
83
+ * @param {any} source
84
+ * @param {boolean} [bare]
85
+ */
86
+ function openPhrase(source, bare = false) {
37
87
  return {
38
- source, fold: EMPTY, where: EMPTY, groupby: EMPTY, orderby: EMPTY, ret: EMPTY,
88
+ source, bare,
89
+ fold: EMPTY, bindings: EMPTY, where: EMPTY, groupby: EMPTY, orderby: EMPTY, ret: EMPTY,
39
90
  };
40
91
  }
41
92
 
93
+ /** Whether no clause of the phrase has been filled. */
94
+ function untouched(phrase) {
95
+ return phrase.fold === EMPTY && phrase.bindings === EMPTY && phrase.where === EMPTY
96
+ && phrase.groupby === EMPTY && phrase.orderby === EMPTY && phrase.ret === EMPTY;
97
+ }
98
+
99
+ /**
100
+ * The expression a phrase's items come from, as a `$for` source: packed
101
+ * (D4 unpacks the one array item back into the rows) unless the source
102
+ * is a bare provider root.
103
+ * @param {any} phrase
104
+ */
105
+ function iterated(phrase) {
106
+ return phrase.bare ? phrase.source : [phrase.source];
107
+ }
108
+
109
+ /**
110
+ * A closed phrase as the SOURCE of another `$for` (a join side): a bare
111
+ * untouched root stays bare; anything else is packed.
112
+ * @param {any} phrase
113
+ */
114
+ function packed(phrase) {
115
+ return phrase.bare && untouched(phrase) ? phrase.source : [closePhrase(phrase)];
116
+ }
117
+
118
+ /**
119
+ * A `selectMany` projection: the projected value iterated one level —
120
+ * an array member's elements, a constructed array's members, a scalar
121
+ * as itself — so `Seq<R[]>` really flattens to `Seq<R>`. The nested
122
+ * phrase rebinds `it` legally: its source is evaluated in the enclosing
123
+ * scope (the row), its body sees the element.
124
+ * @param {any} projection - a captured expression
125
+ * @returns {any}
126
+ */
127
+ export function fanProjection(projection) {
128
+ return { $for: { it: projection }, $return: '$it' };
129
+ }
130
+
131
+ /**
132
+ * A deep, independent copy of an emitted query document. Plain data
133
+ * only, which is exactly what a document is — every captured expression
134
+ * has already passed the JSON-domain boundary in `expression.js`, so
135
+ * there is nothing here a structural copy would lose.
136
+ * @param {any} node
137
+ * @returns {any}
138
+ */
139
+ export function snapshot(node) {
140
+ if (node === null || typeof node !== 'object') return node;
141
+ if (Array.isArray(node)) return node.map(snapshot);
142
+ /** @type {Record<string, any>} */
143
+ const out = {};
144
+ for (const key of Object.keys(node)) defineOwn(out, key, snapshot(node[key]));
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Assign an OWN property, so a `__proto__` member stays a member instead
150
+ * of silently replacing the object's prototype and vanishing.
151
+ * @param {Record<string, any>} target
152
+ * @param {string} key
153
+ * @param {any} value
154
+ */
155
+ function defineOwn(target, key, value) {
156
+ Object.defineProperty(target, key,
157
+ { value, writable: true, enumerable: true, configurable: true });
158
+ }
159
+
42
160
  /** Whether every slot AFTER `slot` is still empty — chain order must
43
161
  * agree with the phrase's fixed semantic order. */
44
162
  function laterSlotsFree(phrase, slot) {
@@ -57,12 +175,11 @@ function slotFree(phrase, slot) {
57
175
 
58
176
  /** Close a phrase into a query expression. */
59
177
  function closePhrase(phrase) {
60
- const untouched = phrase.fold === EMPTY && phrase.where === EMPTY
61
- && phrase.groupby === EMPTY && phrase.orderby === EMPTY && phrase.ret === EMPTY;
62
- if (untouched) return phrase.source;
178
+ if (untouched(phrase)) return phrase.source;
63
179
  const doc = {};
64
180
  if (phrase.fold !== EMPTY) doc.$fold = { acc: phrase.fold };
65
- doc.$for = { it: phrase.source };
181
+ doc.$for = { it: iterated(phrase) };
182
+ if (phrase.bindings !== EMPTY) doc.$let = phrase.bindings;
66
183
  if (phrase.where !== EMPTY) doc.$where = phrase.where;
67
184
  if (phrase.groupby !== EMPTY) doc.$groupby = { g: phrase.groupby };
68
185
  if (phrase.orderby !== EMPTY) {
@@ -72,7 +189,7 @@ function closePhrase(phrase) {
72
189
  // so the member sequence packs into an array constructor and an
73
190
  // empty grouping key reads as null
74
191
  doc.$return = phrase.ret !== EMPTY ? phrase.ret : (phrase.groupby !== EMPTY
75
- ? { key: { $default: ['$g', null] }, items: ['$it'] }
192
+ ? { key: { $default: ['$g', null] }, [GROUP_ITEMS]: ['$it'] }
76
193
  : '$it');
77
194
  return doc;
78
195
  }
@@ -86,10 +203,12 @@ const andJoin = (a, b) => (a === EMPTY ? b : { $and: [a, b] });
86
203
  * (`'$[*]'` for a plain source; a stripped document for
87
204
  * `fromDocument`)
88
205
  * @param {readonly any[]} stages
206
+ * @param {{ bareRoot?: boolean }} [options] - `bareRoot` iterates the
207
+ * root without packing (a provider's own root; see the header)
89
208
  * @returns {any} the emitted query document (plain JSON)
90
209
  */
91
- export function emitDocument(root, stages) {
92
- let phrase = openPhrase(root);
210
+ export function emitDocument(root, stages, options = undefined) {
211
+ let phrase = openPhrase(root, options?.bareRoot === true);
93
212
  /** Close the open phrase and reopen over its result. */
94
213
  const reseat = () => { phrase = openPhrase(closePhrase(phrase)); };
95
214
 
@@ -125,13 +244,24 @@ export function emitDocument(root, stages) {
125
244
  break;
126
245
  case 'join':
127
246
  // the hash-join shape: nested bindings + equality (the engine's
128
- // compile-time rewrite turns exactly this into a hash probe)
247
+ // compile-time rewrite turns exactly this into a hash probe;
248
+ // packed sources keep it — the probe is keyed on the bindings,
249
+ // not on the sources' spelling)
129
250
  phrase = openPhrase({
130
- $for: { it: closePhrase(phrase), it2: stage.inner },
251
+ $for: { it: packed(phrase), it2: stage.innerBare ? stage.inner : [stage.inner] },
131
252
  $where: stage.on,
132
253
  $return: stage.result,
133
254
  });
134
255
  break;
256
+ case 'groupJoin':
257
+ // the matching group bound as an ARRAY value (`$let`, one item)
258
+ // so the projection can index it, fan it and place it in a
259
+ // member; its aggregates fan over the members (expression.js)
260
+ if (!slotFree(phrase, 'bindings')) reseat();
261
+ phrase.bindings = { g: [stage.group] };
262
+ phrase.ret = stage.projection;
263
+ reseat();
264
+ break;
135
265
  case 'aggregate': {
136
266
  // the seeded fold: its own phrase, closed immediately — the
137
267
  // result is one accumulated value, not a tuple stream
package/src/effect.js ADDED
@@ -0,0 +1,65 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The effect descriptor two formats spell identically —
4
+ * `{ run, with? }` — under a machine's `entry`/`exit`/`effects`
5
+ * (FLOW-FORMAT §2) and under an app transition's `effects`
6
+ * (APP-FORMAT §5.1). One shape, one brand, one reader; what differs is
7
+ * only where the props come from, which each pen passes in: the flow
8
+ * pen captures them over the step scope, the app pen leaves them to the
9
+ * action capture already in progress.
10
+ *
11
+ * `run` is a name the pen writes and never resolves — the host's
12
+ * registry owns the handler. What an unregistered name costs differs by
13
+ * engine and neither cost is the pen's: an app document's action loop
14
+ * refuses it (`JA2006`), while `compileFsm` never looks one up at all,
15
+ * because FLOW-FORMAT §1.1 makes effect EXECUTION a non-goal — the
16
+ * descriptor comes back as data and the host decides what to do with it.
17
+ */
18
+
19
+ import { LinqBuildError } from './errors.js';
20
+ import { describeValue } from './json-boundary.js';
21
+
22
+ /** The descriptor brand: how a pen tells `effect()`'s result apart. */
23
+ export const EFFECT = Symbol.for('@jarenjs/linq/effect-descriptor');
24
+
25
+ /**
26
+ * One `{ run, with? }` descriptor, branded and frozen.
27
+ * @param {string} run - the host-registered handler name
28
+ * @param {any} props - the `with` member, or `undefined`
29
+ * @param {(props: any) => any} readProps - how this pen spells the props
30
+ * @returns {any} the effect declaration
31
+ */
32
+ export function effectDescriptor(run, props, readProps) {
33
+ if (typeof run !== 'string' || run === '') {
34
+ throw new LinqBuildError('JL0101',
35
+ `effect() takes the handler name as a non-empty string, got ${describeValue(run)}`, '/run');
36
+ }
37
+ const out = { run };
38
+ if (props !== undefined) out.with = readProps(props);
39
+ Object.defineProperty(out, EFFECT, { value: true, enumerable: false });
40
+ return Object.freeze(out);
41
+ }
42
+
43
+ /**
44
+ * An effects list, as a document carries it: plain `{ run, with? }`
45
+ * objects, the brand dropped.
46
+ * @param {any} list
47
+ * @param {string} what - the member, for the message
48
+ * @returns {any[]}
49
+ */
50
+ export function readEffects(list, what) {
51
+ if (!Array.isArray(list)) {
52
+ throw new LinqBuildError('JL0101',
53
+ `${what} is an array of effect() descriptors, got ${describeValue(list)}`);
54
+ }
55
+ return list.map((declared, i) => {
56
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)
57
+ || declared[EFFECT] !== true) {
58
+ throw new LinqBuildError('JL0101',
59
+ `${what}[${i}] is effect(run, with?), got ${describeValue(declared)}`);
60
+ }
61
+ const out = { run: declared.run };
62
+ if (declared.with !== undefined) out.with = declared.with;
63
+ return out;
64
+ });
65
+ }
package/src/errors.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * contract: every failure carries a stable `code` (JL0xxx build, JL2xxx
5
5
  * runtime), a bare `reason`, a composed `message`, and — where a
6
6
  * document position exists — a `docPath`. The normative table lives in
7
- * docs/LINQ-FORMAT.md §9, proven in sync with `LINQ_CODES` below by a
7
+ * docs/QUERY-PEN.md §9, proven in sync with `LINQ_CODES` below by a
8
8
  * test.
9
9
  */
10
10
 
@@ -12,7 +12,7 @@ import { CodedError } from '@jarenjs/core/errors';
12
12
 
13
13
  /**
14
14
  * The runtime code table (the `CSV_CODES` shape): one entry per code
15
- * this package can raise, proven in sync with LINQ-FORMAT.md §9's
15
+ * this package can raise, proven in sync with QUERY-PEN.md §9's
16
16
  * normative table by a test.
17
17
  */
18
18
  export const LINQ_CODES = Object.freeze({
@@ -22,10 +22,22 @@ export const LINQ_CODES = Object.freeze({
22
22
  JL0004: 'an undeclared or reserved parameter name was used',
23
23
  JL0005: 'an operator was used invalidly at build time',
24
24
  JL0006: 'an unsupported operator was invoked',
25
+ JL0007: 'a provider serves several entity roots and has no root of its own',
26
+ JL0101: 'a pen received a value it cannot spell: not JSON, or not what the keyword takes',
27
+ JL0102: 'a pen was asked for a construct the format cannot carry',
28
+ JL0103: 'a $defs name collision, a dangling ref, or an unnamed recursion',
29
+ JL0104: 'a pen-owned keyword through meta(), or an external a captured rule did not declare',
30
+ JL0105: 'a relation hop cannot lower: a composite or undeclared key, an incomplete many-to-many entry, or a malformed relation record',
31
+ JL0106: 'a migration step names a table the target model does not declare, or a draft it cannot match',
32
+ JL0107: 'a client operation named a member that is not the relation kind it needs',
25
33
  JL2001: 'first/single found no element',
26
34
  JL2002: 'single found more than one element',
27
35
  JL2003: 'elementAt is out of range',
28
36
  JL2004: 'an asynchronous provider cannot back the synchronous surface',
37
+ JL2005: 'a push queue was fed after it ended',
38
+ JL2006: 'a provider answered an element terminal with something other than one array',
39
+ JL2007: 'a ledger settlement named a ref that settles no started record',
40
+ JL2008: 'a federated fetch reached its row or byte budget',
29
41
  });
30
42
 
31
43
  /**
@@ -42,13 +54,60 @@ export const LINQ_CODES = Object.freeze({
42
54
  * compileTypeTest })`, e.g. `createTypeTestCompiler()` from
43
55
  * `@jarenjs/validate/query`)
44
56
  * - `JL0004` — a parameter was referenced without being declared via
45
- * `.params({...})`, or a declared name is reserved (`it`, `acc`,
46
- * `g` — the document's own binding names)
57
+ * `.params({...})`, a declared name is reserved (`it`, `it2`, `acc`,
58
+ * `g`, and the relation-hop bindings `r1`, `r2`, … — the document's
59
+ * own binding names), a binding is not query data, or the two sides
60
+ * of a `join`/`groupJoin`/`concat` bind one name to different values
47
61
  * - `JL0005` — an operator was used invalidly at build time (`thenBy`
48
- * without `orderBy`, `all()` off a plain path, a negative
49
- * `skip`/`take`, a value that cannot embed in a document)
62
+ * without `orderBy`, `all()` off an operator result rather than a
63
+ * path, a member read off a to-many relation before `all()`, a
64
+ * negative `skip`/`take`, a value that cannot embed in a document)
50
65
  * - `JL0006` — an operator the mapping table records as
51
66
  * `unsupported` was invoked (`zip`); the table names the reason
67
+ * - `JL0007` — `from()`/`fromAsync()` received a provider that serves
68
+ * several entity roots (`roots`) and has no `root` of its own — a
69
+ * store with entities; chain over one of them (`store.entity(name)`)
70
+ *
71
+ * The pens (`@jarenjs/linq/schema`, `/model`, `/jslt`, `/migration`,
72
+ * `/contract`, `/flow`) and the client (`/db`; LINQ-FORMAT.md §1.3)
73
+ * refuse with the `JL01xx` codes:
74
+ *
75
+ * - `JL0101` — a pen received a value it cannot spell: a function,
76
+ * symbol, bigint, `NaN`, `±Infinity`, `-0`, a class instance or a
77
+ * cycle where JSON was needed, or a value that is not what the
78
+ * keyword takes (`min('x')`, a non-builder member)
79
+ * - `JL0102` — a construct the format cannot carry: a function
80
+ * `refine`/`transform`, a coercion the normalizer would never run,
81
+ * closed objects under `allOf`, an annotation on `never()`, an
82
+ * `apply()` as a bare object member, a `match` of `{}`, a path
83
+ * template form CONTRACT-FORMAT §4.2 reserves, an operation `kind`
84
+ * outside the three, a flow guard given as a plain string
85
+ * (FLOW-FORMAT §3 makes a non-`$` literal vacuously true), a state
86
+ * or node id no declaration carries
87
+ * - `JL0103` — two distinct builders under one `$defs` name, a
88
+ * `ref()` no definition answers, or a `lazy()` that is not named
89
+ * - `JL0104` — a pen-owned keyword written through `meta()`, or a
90
+ * captured rule naming an external it did not declare (a `check()`
91
+ * or `body()` external other than `root`/`path` and, for a body,
92
+ * its declared parameters; a `compute()` external at all, or a flow
93
+ * guard/`with`/node-query/`select` external at all — both flow
94
+ * engines evaluate with one `$` and nothing else)
95
+ * - `JL0105` — a relation hop on the chain (the query pen) cannot
96
+ * lower: the member is a many-to-many relation, whose join table is
97
+ * not a queryable root in this version (`load({ include })` reads the
98
+ * memberships); or the relation's key column or the key it references
99
+ * is composite or undeclared; or the provider's relation table holds
100
+ * something that is not a relation record
101
+ * - `JL0106` — a migration step names an entity or collection the
102
+ * target model does not declare (`transform`, `assert`, `derive`),
103
+ * or a `transform` over a planned document finds no draft to
104
+ * replace, or two drafts for one name
105
+ * - `JL0107` — the client was handed a member that is not the relation
106
+ * kind the operation needs: `include()` picks a declared relation
107
+ * member (a scalar member, or a name the model does not declare, is
108
+ * refused naming the declared ones); `link()`/`unlink()` attach
109
+ * many-to-many memberships only (a to-one or to-many relation is
110
+ * refused naming its kind)
52
111
  */
53
112
  export class LinqBuildError extends CodedError {
54
113
  /**
@@ -77,6 +136,19 @@ export class LinqBuildError extends CodedError {
77
136
  * `Sequence` terminal is a value (`toArray(): T[]`), so a promise
78
137
  * cannot be returned under that type; emit `toDocument()` and await
79
138
  * the provider directly instead.
139
+ * - `JL2005` — `feed()` was called on a push queue after `end()`
140
+ * closed it (a condition of the running stream, not of the build)
141
+ * - `JL2007` — `createDbLedger`'s `commit`/`fail` named a ref that
142
+ * settles no started record: the key expired, was reclaimed under a
143
+ * newer generation, or was settled already (DB-CLIENT.md §2.6)
144
+ * - `JL2006` — a provider answered an element terminal (`toArray`,
145
+ * `first`, …) with something other than exactly one array; the
146
+ * emitted document is an array constructor, so a conforming
147
+ * `execute()` never answers `undefined` there
148
+ * - `JL2008` — a `federate()` fetch reached one side's row or byte
149
+ * budget. A budget is a REFUSAL, not a spill: the fetch stops at the
150
+ * row that would have broken it, every cursor it opened is closed,
151
+ * and the reason names the side and the bound (QUERY-PEN.md §13)
80
152
  */
81
153
  export class LinqRuntimeError extends CodedError {
82
154
  /**