@jarenjs/linq 0.34.0

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.
@@ -0,0 +1,213 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `mapAsync` — the ONE explicit bounded-concurrency boundary
4
+ * (LINQ-FORMAT.md §11). Element-wise asynchronous work (an HTTP call, a
5
+ * model call, a file read per row) happens here and nowhere else: there
6
+ * is no parallel universe of `selectAwait`-shaped operators, and a
7
+ * per-element async *predicate* is `mapAsync` then `where`, by design.
8
+ *
9
+ * `concurrency` is REQUIRED — the unbounded default is how libraries
10
+ * like this take down a downstream service. `mode` reuses the
11
+ * `createTaskEffect` vocabulary (`parallel`/`concat`/`switch`/
12
+ * `exhaust`) so a reader who knows one knows the other. Failure is
13
+ * fail-closed: the first rejection aborts every in-flight callback and
14
+ * the source, the `compileDag` discipline.
15
+ */
16
+
17
+ import { LinqBuildError } from './errors.js';
18
+
19
+ const MODES = new Set(['parallel', 'concat', 'switch', 'exhaust']);
20
+
21
+ /**
22
+ * Validate a mapAsync options bag at BUILD time.
23
+ * @param {any} options
24
+ * @returns {{ concurrency: number, mode: string, ordered: boolean }}
25
+ */
26
+ export function normalizeMapAsyncOptions(options) {
27
+ if (options === null || typeof options !== 'object'
28
+ || !Number.isInteger(options.concurrency) || options.concurrency < 1) {
29
+ throw new LinqBuildError('JL0005',
30
+ 'mapAsync requires { concurrency: <positive integer> } — an unbounded default is a denial of service waiting for a slow downstream');
31
+ }
32
+ const mode = options.mode ?? 'parallel';
33
+ if (!MODES.has(mode)) {
34
+ throw new LinqBuildError('JL0005',
35
+ `mapAsync mode must be one of parallel|concat|switch|exhaust, got '${mode}'`);
36
+ }
37
+ return { concurrency: options.concurrency, mode, ordered: options.ordered !== false };
38
+ }
39
+
40
+ /**
41
+ * Apply one mapAsync stage over an async item stream. The returned
42
+ * generator owns an AbortController: early termination (a downstream
43
+ * `break`/`return`) and the first rejection both abort every in-flight
44
+ * callback; the rejection then rethrows (fail closed, first failure
45
+ * wins).
46
+ * @param {AsyncIterator<any>} items
47
+ * @param {(item: any, signal: AbortSignal) => any} fn
48
+ * @param {{ concurrency: number, mode: string, ordered: boolean }} opts
49
+ * @returns {AsyncGenerator<any>}
50
+ */
51
+ export async function* applyMapAsync(items, fn, opts) {
52
+ const controller = new AbortController();
53
+ const { signal } = controller;
54
+ /** The pipeline's own failure, so source cleanup cannot displace it.
55
+ * @type {{ reason: any } | null} */
56
+ let failure = null;
57
+
58
+ try {
59
+ if (opts.mode === 'concat' || (opts.mode === 'parallel' && opts.concurrency === 1)) {
60
+ // strictly sequential; `concat` ignores the window by definition,
61
+ // and parallel-of-one degenerates to it — but `switch`/`exhaust`
62
+ // keep their racing semantics even at one in-flight task
63
+ for (;;) {
64
+ const step = await items.next();
65
+ if (step.done) return;
66
+ yield await fn(step.value, signal);
67
+ if (signal.aborted) return;
68
+ }
69
+ }
70
+
71
+ if (opts.mode === 'switch' || opts.mode === 'exhaust') {
72
+ // one in-flight task, with the source pulled EAGERLY: the next
73
+ // item races the running work. `switch` supersedes the task when
74
+ // a newer item wins the race (the task's own signal aborts and
75
+ // its result is discarded); `exhaust` drops the newer item.
76
+ const PULL = Symbol('pull');
77
+ let pending = items.next().then((step) => ({ [PULL]: step }));
78
+ let task = null; // { promise, abort }
79
+ let exhausted = false;
80
+ while (!exhausted || task !== null) {
81
+ const race = task === null
82
+ ? [pending]
83
+ : [pending, task.promise.then((value) => ({ value }))];
84
+ const won = await Promise.race(exhausted && task !== null ? [race[1]] : race);
85
+ if (won !== undefined && PULL in won) {
86
+ const step = won[PULL];
87
+ if (step.done) {
88
+ exhausted = true;
89
+ pending = new Promise(() => {}); // never resolves again
90
+ continue;
91
+ }
92
+ pending = items.next().then((s) => ({ [PULL]: s }));
93
+ if (task !== null) {
94
+ if (opts.mode === 'exhaust') continue; // drop while busy
95
+ task.abort.abort(); // switch: supersede, discard
96
+ task.promise.catch(() => {}); // superseded rejections are moot
97
+ task = null;
98
+ }
99
+ const abort = new AbortController();
100
+ const onOuter = () => abort.abort();
101
+ signal.addEventListener('abort', onOuter, { once: true });
102
+ const current = {
103
+ abort,
104
+ promise: Promise.resolve(fn(step.value, abort.signal))
105
+ .finally(() => signal.removeEventListener('abort', onOuter)),
106
+ };
107
+ task = current;
108
+ continue;
109
+ }
110
+ // the task settled while still current
111
+ const { value } = /** @type {{ value: any }} */ (won);
112
+ task = null;
113
+ yield value;
114
+ }
115
+ return;
116
+ }
117
+
118
+ // parallel: a sliding window of `concurrency` in-flight tasks
119
+ const window = [];
120
+ let sourceDone = false;
121
+ const pull = async () => {
122
+ const step = await items.next();
123
+ if (step.done) { sourceDone = true; return null; }
124
+ const promise = Promise.resolve(fn(step.value, signal));
125
+ // a rejection must wait its turn in the ordered window without
126
+ // firing unhandledRejection while an earlier task is in flight
127
+ promise.catch(() => {});
128
+ return { promise };
129
+ };
130
+ if (opts.ordered) {
131
+ // completion order = source order; the window buffers at most
132
+ // `concurrency` results (the documented buffering cost)
133
+ while (!sourceDone && window.length < opts.concurrency) {
134
+ const task = await pull();
135
+ if (task !== null) window.push(task.promise);
136
+ }
137
+ while (window.length > 0) {
138
+ const value = await window.shift();
139
+ if (!sourceDone) {
140
+ const task = await pull();
141
+ if (task !== null) window.push(task.promise);
142
+ }
143
+ yield value;
144
+ }
145
+ return;
146
+ }
147
+ // unordered: yield on completion
148
+ let nextId = 0;
149
+ const inflight = new Map();
150
+ const start = async () => {
151
+ const step = await items.next();
152
+ if (step.done) { sourceDone = true; return; }
153
+ const id = nextId++;
154
+ inflight.set(id, Promise.resolve(fn(step.value, signal)).then(
155
+ (value) => ({ id, value }),
156
+ // the task's identity rides in an internal ENVELOPE, never on the
157
+ // rejection value. Stamping the value mutated whatever the handler
158
+ // threw: a frozen error became a different TypeError, a thrown
159
+ // string came back boxed, an ordinary error grew a private
160
+ // property, and a hostile proxy could break normalization outright.
161
+ (error) => { throw new TaskFailure(id, error); }));
162
+ };
163
+ while (!sourceDone && inflight.size < opts.concurrency) await start();
164
+ while (inflight.size > 0) {
165
+ let settled;
166
+ try {
167
+ settled = await Promise.race(inflight.values());
168
+ }
169
+ catch (err) {
170
+ if (err instanceof TaskFailure) {
171
+ inflight.delete(err.id);
172
+ throw err.reason; // the ORIGINAL value, unmodified
173
+ }
174
+ throw err;
175
+ }
176
+ inflight.delete(settled.id);
177
+ if (!sourceDone) await start();
178
+ yield settled.value;
179
+ }
180
+ }
181
+ catch (err) {
182
+ failure = { reason: err };
183
+ throw err;
184
+ }
185
+ finally {
186
+ controller.abort(); // early termination aborts in-flight work
187
+ if (typeof items.return === 'function') {
188
+ // The TASK's failure is the primary one — it is why the caller is
189
+ // here — so a failing close travels ALONGSIDE it as an aggregate
190
+ // rather than replacing it, and stands alone only when the pipeline
191
+ // itself succeeded. Composed as a rejection the `await` adopts,
192
+ // because a `throw` here would be the very substitution this
193
+ // avoids: it discards whatever completion the block was carrying.
194
+ await Promise.resolve(items.return(undefined)).then(undefined,
195
+ (cleanupError) => Promise.reject(failure === null
196
+ ? cleanupError
197
+ : new AggregateError([failure.reason, cleanupError],
198
+ 'mapAsync failed, and closing the source failed too')));
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The internal envelope carrying which in-flight task rejected, so the
205
+ * rejection VALUE never has to be touched to find out.
206
+ */
207
+ class TaskFailure {
208
+ /** @param {number} id @param {any} reason */
209
+ constructor(id, reason) {
210
+ this.id = id;
211
+ this.reason = reason;
212
+ }
213
+ }
@@ -0,0 +1,212 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Chain → document emission: the stage list a `Sequence` carries
4
+ * folds into ONE Jaren query document (QUERY-FORMAT.md). The item
5
+ * binding is always named `it` — nested phrases shadow it legally, the
6
+ * emitted JSON stays hand-readable, and every captured expression
7
+ * references `$it…` regardless of chain depth.
8
+ *
9
+ * Folding rule: consecutive stages join one FLWOR phrase while their
10
+ * clause slots stay free AND the chain order agrees with the phrase's
11
+ * fixed semantic order (`$where` → `$groupby` → `$orderby` → `$return`,
12
+ * QUERY-FORMAT §6.1). A stage that would violate either closes the
13
+ * phrase (default `$return: "$it"`) and opens a new segment iterating
14
+ * the closed one — `orderBy().where()` really does sort first and
15
+ * filter second, exactly as written.
16
+ *
17
+ * Empty clause slots carry {@link EMPTY}, never `null`. `null` is a
18
+ * legal query expression — `where(() => null)` filters everything out,
19
+ * `select(() => null)` projects nulls, `groupBy(() => null)` is one
20
+ * null-keyed group — so a slot that used `null` for "nothing here" could
21
+ * not tell an absent clause from a null-valued one, and silently emitted
22
+ * the document WITHOUT the clause. Every such query then returned its
23
+ * unfiltered, unprojected source.
24
+ */
25
+
26
+ import { LinqBuildError } from './errors.js';
27
+
28
+ /** The fixed clause order a phrase may fill left-to-right. */
29
+ const SLOT_ORDER = ['where', 'groupby', 'orderby', 'return'];
30
+
31
+ /** The "this clause slot is unfilled" sentinel: a fresh object, so no
32
+ * value a caller can express is ever mistaken for it. */
33
+ const EMPTY = Symbol('linq.emptySlot');
34
+
35
+ /** An open FLWOR phrase under construction. */
36
+ function openPhrase(source) {
37
+ return {
38
+ source, fold: EMPTY, where: EMPTY, groupby: EMPTY, orderby: EMPTY, ret: EMPTY,
39
+ };
40
+ }
41
+
42
+ /** Whether every slot AFTER `slot` is still empty — chain order must
43
+ * agree with the phrase's fixed semantic order. */
44
+ function laterSlotsFree(phrase, slot) {
45
+ for (let i = SLOT_ORDER.indexOf(slot) + 1; i < SLOT_ORDER.length; i++) {
46
+ const later = SLOT_ORDER[i];
47
+ if (phrase[later === 'return' ? 'ret' : later] !== EMPTY) return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /** Whether `slot` may be filled fresh (itself empty, order respected). */
53
+ function slotFree(phrase, slot) {
54
+ return phrase[slot === 'return' ? 'ret' : slot] === EMPTY
55
+ && laterSlotsFree(phrase, slot);
56
+ }
57
+
58
+ /** Close a phrase into a query expression. */
59
+ 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;
63
+ const doc = {};
64
+ if (phrase.fold !== EMPTY) doc.$fold = { acc: phrase.fold };
65
+ doc.$for = { it: phrase.source };
66
+ if (phrase.where !== EMPTY) doc.$where = phrase.where;
67
+ if (phrase.groupby !== EMPTY) doc.$groupby = { g: phrase.groupby };
68
+ if (phrase.orderby !== EMPTY) {
69
+ doc.$orderby = phrase.orderby.length === 1 ? phrase.orderby[0] : phrase.orderby;
70
+ }
71
+ // the default group shape: an object member takes exactly one item,
72
+ // so the member sequence packs into an array constructor and an
73
+ // empty grouping key reads as null
74
+ doc.$return = phrase.ret !== EMPTY ? phrase.ret : (phrase.groupby !== EMPTY
75
+ ? { key: { $default: ['$g', null] }, items: ['$it'] }
76
+ : '$it');
77
+ return doc;
78
+ }
79
+
80
+ /** Combine two predicates. */
81
+ const andJoin = (a, b) => (a === EMPTY ? b : { $and: [a, b] });
82
+
83
+ /**
84
+ * Emit the query document for a stage list.
85
+ * @param {any} root - the root expression the items come from
86
+ * (`'$[*]'` for a plain source; a stripped document for
87
+ * `fromDocument`)
88
+ * @param {readonly any[]} stages
89
+ * @returns {any} the emitted query document (plain JSON)
90
+ */
91
+ export function emitDocument(root, stages) {
92
+ let phrase = openPhrase(root);
93
+ /** Close the open phrase and reopen over its result. */
94
+ const reseat = () => { phrase = openPhrase(closePhrase(phrase)); };
95
+
96
+ for (const stage of stages) {
97
+ switch (stage.kind) {
98
+ case 'where':
99
+ // a second where in the same phrase COMBINES ($and); only a
100
+ // later-slot occupant (orderby/groupby/return) forces a segment
101
+ if (!laterSlotsFree(phrase, 'where')) reseat();
102
+ phrase.where = andJoin(phrase.where, stage.predicate);
103
+ break;
104
+ case 'select': // also selectMany: a multi-item projection flattens
105
+ if (!slotFree(phrase, 'return')) reseat();
106
+ phrase.ret = stage.projection;
107
+ reseat(); // later operators see projected items
108
+ break;
109
+ case 'orderBy':
110
+ if (!slotFree(phrase, 'orderby')) reseat();
111
+ phrase.orderby = [stage.spec];
112
+ break;
113
+ case 'thenBy': {
114
+ if (phrase.orderby === EMPTY || phrase.ret !== EMPTY) {
115
+ throw new LinqBuildError('JL0005',
116
+ 'thenBy/thenByDescending must directly follow orderBy/orderByDescending');
117
+ }
118
+ phrase.orderby = [...phrase.orderby, stage.spec];
119
+ break;
120
+ }
121
+ case 'groupBy':
122
+ if (!slotFree(phrase, 'groupby')) reseat();
123
+ phrase.groupby = stage.key;
124
+ reseat(); // later operators see { key, items } groups
125
+ break;
126
+ case 'join':
127
+ // the hash-join shape: nested bindings + equality (the engine's
128
+ // compile-time rewrite turns exactly this into a hash probe)
129
+ phrase = openPhrase({
130
+ $for: { it: closePhrase(phrase), it2: stage.inner },
131
+ $where: stage.on,
132
+ $return: stage.result,
133
+ });
134
+ break;
135
+ case 'aggregate': {
136
+ // the seeded fold: its own phrase, closed immediately — the
137
+ // result is one accumulated value, not a tuple stream
138
+ if (phrase.fold !== EMPTY || phrase.where !== EMPTY || phrase.groupby !== EMPTY
139
+ || phrase.orderby !== EMPTY || phrase.ret !== EMPTY) reseat();
140
+ phrase.fold = stage.seed;
141
+ phrase.ret = stage.step;
142
+ reseat();
143
+ break;
144
+ }
145
+ case 'skip':
146
+ phrase = openPhrase({ $subsequence: [closePhrase(phrase), stage.count] });
147
+ break;
148
+ case 'take':
149
+ phrase = openPhrase({ $subsequence: [closePhrase(phrase), 0, stage.count] });
150
+ break;
151
+ case 'distinct':
152
+ phrase = openPhrase({ $distinct: closePhrase(phrase) });
153
+ break;
154
+ case 'reverse':
155
+ phrase = openPhrase({ $reverse: closePhrase(phrase) });
156
+ break;
157
+ case 'concat':
158
+ phrase = openPhrase({ $seq: [closePhrase(phrase), stage.other] });
159
+ break;
160
+ case 'defaultIfEmpty':
161
+ phrase = openPhrase({ $default: [closePhrase(phrase), stage.fallback] });
162
+ break;
163
+ case 'ofType': // keep only items the schema accepts
164
+ // the second $valid argument is a SCHEMA literal — verbatim by
165
+ // the registry's `schema` parameter kind, never an expression
166
+ phrase = openPhrase(closePhrase(phrase));
167
+ phrase.where = { $valid: ['$it', stage.schema] };
168
+ break;
169
+ case 'cast': // assert every item against the schema
170
+ phrase = openPhrase(closePhrase(phrase));
171
+ phrase.ret = { $assert: ['$it', stage.schema] };
172
+ break;
173
+ /* c8 ignore next 2 -- stages are produced by Sequence alone */
174
+ default:
175
+ throw new LinqBuildError('JL0005', `unknown stage kind '${stage.kind}'`);
176
+ }
177
+ }
178
+ return closePhrase(phrase);
179
+ }
180
+
181
+ /**
182
+ * Wrap the emitted expression per terminal so results extract without
183
+ * ambiguity: the engine maps a result to `undefined | item | array of
184
+ * items`, and a SINGLE item that is itself an array would be
185
+ * indistinguishable from two items — so every element-window terminal
186
+ * emits `[window]` (one array item, Rule 3 constructor: elements
187
+ * flatten) and reads its elements.
188
+ * @param {any} expr - the emitted chain expression
189
+ * @param {string} terminal
190
+ * @param {readonly any[]} [args]
191
+ * @returns {any}
192
+ */
193
+ export function wrapTerminal(expr, terminal, args = []) {
194
+ switch (terminal) {
195
+ case 'toArray': return [expr];
196
+ case 'first': return [{ $subsequence: [expr, 0, 1] }];
197
+ case 'single': return [{ $subsequence: [expr, 0, 2] }];
198
+ case 'last': return [{ $subsequence: [{ $reverse: expr }, 0, 1] }];
199
+ case 'elementAt': return [{ $subsequence: [expr, args[0], 1] }];
200
+ case 'count': return { $count: expr };
201
+ case 'sum': return { $sum: expr };
202
+ case 'average': return { $avg: expr };
203
+ case 'min': return { $min: expr };
204
+ case 'max': return { $max: expr };
205
+ case 'exists': return { $exists: expr };
206
+ case 'some': return { $some: { it: expr }, $satisfies: args[0] };
207
+ case 'every': return { $every: { it: expr }, $satisfies: args[0] };
208
+ /* c8 ignore next 2 -- terminals are produced by Sequence alone */
209
+ default:
210
+ throw new LinqBuildError('JL0005', `unknown terminal '${terminal}'`);
211
+ }
212
+ }
package/src/errors.js ADDED
@@ -0,0 +1,94 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Error types for @jarenjs/linq, built on `@jarenjs/core`'s coded
4
+ * contract: every failure carries a stable `code` (JL0xxx build, JL2xxx
5
+ * runtime), a bare `reason`, a composed `message`, and — where a
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
8
+ * test.
9
+ */
10
+
11
+ import { CodedError } from '@jarenjs/core/errors';
12
+
13
+ /**
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
16
+ * normative table by a test.
17
+ */
18
+ export const LINQ_CODES = Object.freeze({
19
+ JL0001: 'from() received neither an iterable nor a provider',
20
+ JL0002: 'an expression proxy escaped its capture callback',
21
+ JL0003: 'ofType/cast need an injected compileTypeTest',
22
+ JL0004: 'an undeclared or reserved parameter name was used',
23
+ JL0005: 'an operator was used invalidly at build time',
24
+ JL0006: 'an unsupported operator was invoked',
25
+ JL2001: 'first/single found no element',
26
+ JL2002: 'single found more than one element',
27
+ JL2003: 'elementAt is out of range',
28
+ JL2004: 'an asynchronous provider cannot back the synchronous surface',
29
+ });
30
+
31
+ /**
32
+ * A defect in how the query was BUILT — raised while capturing
33
+ * expressions or emitting the document, before anything runs. Codes:
34
+ *
35
+ * - `JL0001` — `from()`/`fromDocument()` received a source that is
36
+ * neither an iterable nor a provider (`execute` duck)
37
+ * - `JL0002` — an expression proxy escaped the callback it was handed
38
+ * to (stored and reused across captures); the emitted document
39
+ * would be nonsense, so the build fails instead
40
+ * - `JL0003` — `ofType`/`cast` compile schema operators, which need
41
+ * the injected `compileTypeTest` hook (`from(src, {
42
+ * compileTypeTest })`, e.g. `createTypeTestCompiler()` from
43
+ * `@jarenjs/validate/query`)
44
+ * - `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)
47
+ * - `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)
50
+ * - `JL0006` — an operator the mapping table records as
51
+ * `unsupported` was invoked (`zip`); the table names the reason
52
+ */
53
+ export class LinqBuildError extends CodedError {
54
+ /**
55
+ * @param {string} code
56
+ * @param {string} reason - The bare reason; `message` is composed per
57
+ * the coded contract.
58
+ * @param {string} [docPath] - JSON Pointer into the emitted query
59
+ * document, where one exists.
60
+ * @param {Error} [cause]
61
+ */
62
+ constructor(code, reason, docPath, cause) {
63
+ super('LinqBuildError', code, reason, docPath,
64
+ cause !== undefined ? { cause } : undefined);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A failure while a terminal operation ran. Codes:
70
+ *
71
+ * - `JL2001` — `first()`/`single()` over an empty sequence (the
72
+ * `OrDefault` variants return the default instead)
73
+ * - `JL2002` — `single()`/`singleOrDefault()` over two or more
74
+ * elements
75
+ * - `JL2003` — `elementAt(i)` with no element at position `i`
76
+ * - `JL2004` — a provider's `execute()` answered a promise. A
77
+ * `Sequence` terminal is a value (`toArray(): T[]`), so a promise
78
+ * cannot be returned under that type; emit `toDocument()` and await
79
+ * the provider directly instead.
80
+ */
81
+ export class LinqRuntimeError extends CodedError {
82
+ /**
83
+ * @param {string} code
84
+ * @param {string} reason - The bare reason; `message` is composed per
85
+ * the coded contract.
86
+ * @param {string} [docPath] - JSON Pointer into the emitted query
87
+ * document, where one exists.
88
+ * @param {Error} [cause]
89
+ */
90
+ constructor(code, reason, docPath, cause) {
91
+ super('LinqRuntimeError', code, reason, docPath,
92
+ cause !== undefined ? { cause } : undefined);
93
+ }
94
+ }