@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.
- package/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- 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
|
-
/**
|
|
36
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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] },
|
|
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:
|
|
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/
|
|
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
|
|
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({...})`,
|
|
46
|
-
* `g` — the document's
|
|
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
|
|
49
|
-
*
|
|
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
|
/**
|