@omgbase/oqx 0.9.0 → 0.10.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/CHANGELOG.md ADDED
@@ -0,0 +1,214 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@omgbase/oqx` are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
+ [Semantic Versioning](https://semver.org/) (pre-1.0: a minor bump may break).
6
+
7
+ ## [0.10.1] - 2026-09-23
8
+
9
+ Metadata and documentation only; no code changes.
10
+
11
+ ### Added
12
+ - `LICENSE` file (MIT) — `package.json` previously said ISC with no license text.
13
+ - Package metadata: `author`, `repository`, `homepage`, `bugs`, `keywords`,
14
+ `sideEffects: false`, and a single consistent `description`.
15
+ - This changelog, shipped in the package.
16
+ - GitHub Actions CI (typecheck, test, build on Node 22 and 24).
17
+ - README: an "Exports" reference for every public export, the
18
+ `DataContext.callFunction` / `callMethod` extension seam (and the
19
+ "unknown function" pitfall of a context that does not delegate to the
20
+ builtins), `OqxError` and its `stage` discriminator, an `IndexedCollection`
21
+ example, `SqliteTableOptions` fields, string-literal escapes, `has(x)` and
22
+ array `contains`, and a License section.
23
+
24
+ ### Changed
25
+ - `engines.node` is now `>=22.13.0` — the floor at which `node:sqlite` (used
26
+ only by the `./sqlite` subpath) is available without a flag. The main entry is
27
+ plain ES2022 ESM and runs on older Node in practice.
28
+ - README "Requirements" states the real floors (see above) and that the package
29
+ is ESM-only.
30
+
31
+ ### Fixed
32
+ - README referred to `oqx.semantics`; the scalar contract is the named export
33
+ `semantics`.
34
+
35
+ ## [0.10.0] - 2026-09-22
36
+
37
+ ### Added
38
+ - `entries(x)` builtin: the explicit bridge from a record to a collection of
39
+ `{ key, value }` entries. Objects yield own enumerable pairs in insertion
40
+ order, arrays yield `(index, element)`, `Map`s yield their entries; absence,
41
+ scalars, and ranges yield nothing. A plain object is still **not** iterable —
42
+ `from ${obj}` is one row.
43
+ - `$key` intrinsic: inside an entry scope, the entry's key. The scope's row is
44
+ the property's *value*, so `$value` and bare names read the value.
45
+ `$key` exists only in entry scopes — an array element has no implicit index.
46
+ - A free-function call may be a consumer receiver
47
+ (`entries(prefs) exists { … }`) and may be navigated further.
48
+ - `semantics`: `makeEntry`, `isEntry`, `entriesOf` (the entry tag is a
49
+ non-enumerable symbol, so `entries(x)` as a plain value reads as an array of
50
+ `{ key, value }` records).
51
+
52
+ ### Changed
53
+ - Engine: one `enter(row, parent, extra)` now creates every scope (top level,
54
+ nested blocks, `from` re-projections, follow seeds/occurrences, fast paths).
55
+
56
+ ## [0.9.0] - 2026-09-21
57
+
58
+ ### Added
59
+ - `none { … }` consumer — true iff the block yields zero rows; the explicit
60
+ complement of `exists`, and how universal quantification is spelled
61
+ (`members none { where !active }`). Works in `where` position and as a
62
+ whole-query directive (`{ consumer: "none", none }`). Not comparable and not a
63
+ projection.
64
+ - `limit N` / `offset N` — bound the row set **after** `where` / `order by` /
65
+ `distinct` and **before** the consumer reduces it, at the top level and in any
66
+ block. `N` is a literal, a `${…}` binding, or an outer reference (`limit ^n`);
67
+ it must be a non-negative integer (eval error otherwise). `limit`/`offset` are
68
+ clause words only when followed by a number, binding, or `^`, so a field with
69
+ that name still reads as a field.
70
+
71
+ ### Changed
72
+ - Engine: `exists` / `none` / `count` fast paths stop at the (offset+1)th match;
73
+ `first` / `single` caps grow by the offset; consumer ops share one `opRows`
74
+ pipeline (matched → ordered → distinct → bounded), which also makes
75
+ `collect distinct { ^x: … }` lifts dedup as documented.
76
+ - SQLite adapter: no SQL `LIMIT` under `first` / `single` when the query carries
77
+ its own `limit` / `offset` (the residual applies them).
78
+
79
+ ## [0.8.0] - 2026-09-21
80
+
81
+ ### Added
82
+ - `$value` intrinsic — the current scope's own row, whatever its type. Absent at
83
+ the root scope. Inside a nested block `$value` is the inner item and `^$value`
84
+ the enclosing row. Never resolved through the `DataContext`.
85
+ - `values` projection mode — after a projection of exactly one item, the row's
86
+ result is that value rather than a `{ name: value }` record. Works at the top
87
+ level and inside any `collect` / `first` / `single` block; composes with
88
+ `distinct` (dedup by the bare value). Cannot be combined with a lift.
89
+ - An unaliased projection item may be any value expression: a bare or dotted
90
+ navigation keys by its last segment; a call / arithmetic / comparison is legal
91
+ only under `values`, otherwise a clear "needs an alias" parse error.
92
+
93
+ ### Fixed
94
+ - `{ has(x) }` used to misparse as select `has` + where `(x)`; it is now the
95
+ alias error above (write `where has(x)`).
96
+
97
+ ## [0.7.0] - 2026-09-19
98
+
99
+ ### Changed
100
+ - **BREAKING:** bare names resolve **locally only**. A bare identifier reads the
101
+ current row/scope; an absent local name stays absent and never falls through
102
+ to an enclosing scope. Outer correlation is always the explicit `^name`
103
+ (exactly one scope out per caret; past the root is absent).
104
+ - **BREAKING:** `DataContext.has` removed — it existed only to stop the scope
105
+ climb; a computed relation now needs only `get`.
106
+ - A consumer receiver may start with `^` (`^people collect { … }`), since a
107
+ named root is reachable from inside a row only as `^root`.
108
+ - Planner helpers / SQLite adapter: a bare identifier in a top-level `where` is
109
+ now unambiguously a column of the scanned rows and is safe to push.
110
+
111
+ ## [0.6.0] - 2026-09-17
112
+
113
+ ### Added
114
+ - `range(s)` builtin: parse a string (`"1..5"`, `"1...5"`, `"..5"`,
115
+ `"2026-01-01..2026-01-31"`, …) into the range value a `lo..hi` literal
116
+ produces; a range passes through unchanged; anything else yields an absent
117
+ range, so `x in range(bad)` is simply false. Bounds must share a scalar domain
118
+ (numeric or ISO-8601).
119
+ - `semantics.parseRangeString`.
120
+
121
+ ## [0.5.0] - 2026-09-16
122
+
123
+ ### Added
124
+ - Ruby-style range literals: `lo..hi` (inclusive), `lo...hi` (exclusive high
125
+ end), and open-ended `..hi` / `lo..`. Used mainly as the right side of `in`
126
+ for interval membership; a range is a general value expression. Bounds
127
+ compare via the ordering rules, so ranges work over numbers and ISO-8601
128
+ date/time strings; an absent value is never covered.
129
+ - `semantics`: `makeRange`, `isRange`, `rangeCovers`; a `membership` branch for
130
+ ranges.
131
+
132
+ ### Fixed
133
+ - Lexer: a decimal point now binds only when a digit follows, so `1..5` no
134
+ longer mis-lexes as `1.` + `.5`.
135
+
136
+ ## [0.4.0] - 2026-09-16
137
+
138
+ ### Added
139
+ - `distinct` modifier on consumers (top-level and nested): dedup the rows a
140
+ consumer reduces by their **projected** value, keeping the first per distinct
141
+ projection in order; an empty projection dedups by row identity. Spelled
142
+ `<op> distinct { … }` or `select distinct …` inside the block.
143
+
144
+ ### Fixed
145
+ - Parser: `looksLikePredicate` skips nested `{ … }` blocks when scanning for a
146
+ top-level operator and recognizes a consumer directive
147
+ (`… exists { }`, `… count distinct { } == N`) as a where-position predicate.
148
+
149
+ ## [0.3.0] - 2026-09-16
150
+
151
+ ### Changed
152
+ - `follow` is now a **per-path** walk: a node reached by N distinct paths yields
153
+ N occurrences (previously collapsed by global identity dedup).
154
+ - Cycles are admitted **once** as `$stop == "cycle"` and never re-expanded
155
+ (previously a revisit produced no occurrence).
156
+ - `$stop` categories are `interior | leaf | frontier | depth | cycle`
157
+ (precedence cycle > frontier > depth > leaf > interior); only `interior` rows
158
+ expand. The old `"continue"` is renamed `"interior"`.
159
+ - `$frontier` covers both frontier and depth cutoffs; `$leaf` stays "no
160
+ successors".
161
+
162
+ ### Added
163
+ - `$ordinal` intrinsic: a deterministic 1..N rank over `(depth, path)`.
164
+
165
+ ### Fixed
166
+ - `follow distinct` now works: collapse occurrences to the minimal
167
+ `(depth, path)` per identity (`by` key, else `ctx.identity`).
168
+
169
+ ## [0.2.0] - 2026-09-16
170
+
171
+ ### Changed
172
+ - Ship a compiled `dist/` (JS + `.d.ts` + source maps) instead of raw `.ts`.
173
+ 0.1.0 pointed `main` / `types` / `exports` at `src/*.ts`, which `tsc` rejects
174
+ under `module: NodeNext` and Node refuses to type-strip under `node_modules`.
175
+ - Added `tsconfig.build.json` (`rewriteRelativeImportExtensions`) and
176
+ `scripts/fix-dts-extensions.mjs` to rewrite `.ts` specifiers left in emitted
177
+ declarations; `build`, `clean`, and `prepublishOnly` scripts.
178
+
179
+ ### Fixed
180
+ - `order by <expr> desc` hoisted rows missing the sort key to the top. Absent
181
+ now sorts last regardless of direction.
182
+
183
+ ## [0.1.0] - 2026-09-14
184
+
185
+ First public release as `@omgbase/oqx`.
186
+
187
+ ### Added
188
+ - The OQX language kernel: lexer, parser, AST; the `oqx` tagged template with
189
+ typed value bindings (prepared-statement semantics); `parse`, `execute`, `run`.
190
+ - Projection (optional `select`, aliases, dotted navigation), `where` predicates,
191
+ `order by`, consumers `collect` / `exists` / `count` / `first` / `single`,
192
+ nested consumer directives over relations, lifts (`^name:`) including
193
+ multi-level `^^` / `^^^` with flatten-append accumulation, `^name` outer
194
+ references, and bounded recursion with `follow` and its intrinsics.
195
+ - `semantics.ts` — the normative scalar contract every backend must obey
196
+ (typed/strict equality, absent-operand comparisons false, CEL-style `in`,
197
+ absent-last sort), backed by a conformance suite.
198
+ - `DataContext` / `DefaultContext` (tier 2), `Engine` / `InMemoryEngine`,
199
+ `QueryPlanner` / `Plan` / `PlannedEngine` (tier 3) with the pushdown helpers
200
+ in `plan.ts`, and two adapters: `IndexedCollection` (hash-index probe) and
201
+ `@omgbase/oqx/sqlite` (`SqliteTable`, real OQX→SQL pushdown over
202
+ `node:sqlite`).
203
+
204
+ [0.10.1]: https://github.com/omgbase/oqx/compare/v0.10.0...HEAD
205
+ [0.10.0]: https://github.com/omgbase/oqx/compare/v0.9.0...v0.10.0
206
+ [0.9.0]: https://github.com/omgbase/oqx/compare/v0.8.0...v0.9.0
207
+ [0.8.0]: https://github.com/omgbase/oqx/compare/v0.7.0...v0.8.0
208
+ [0.7.0]: https://github.com/omgbase/oqx/compare/v0.6.0...v0.7.0
209
+ [0.6.0]: https://github.com/omgbase/oqx/compare/v0.5.0...v0.6.0
210
+ [0.5.0]: https://github.com/omgbase/oqx/compare/v0.4.0...v0.5.0
211
+ [0.4.0]: https://github.com/omgbase/oqx/commits/v0.4.0
212
+ [0.3.0]: https://github.com/omgbase/oqx/commit/aca739b
213
+ [0.2.0]: https://github.com/omgbase/oqx/commit/82fb99e
214
+ [0.1.0]: https://github.com/omgbase/oqx/commit/4552bb5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brendan Baldwin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -143,9 +143,32 @@ oqx`employee: $value from ${people} where name == "Bob"`; // [{ employee
143
143
  ```
144
144
 
145
145
  Inside a nested block `$value` is the inner item; the enclosing row is `^$value`
146
- (§3). At the root scope (before any row) it is absent. Plain objects are **not**
147
- iterable through `$value` — a record stays a record; converting one to a
148
- collection of entries is a separate, explicit step (`entries()`, planned).
146
+ (§3). At the root scope (before any row) it is absent.
147
+
148
+ **`entries(x)` and `$key` — records to collections, explicitly.** A plain object
149
+ is **not** iterable: `from ${obj}` is one row (the object). `entries(obj)`
150
+ converts it into a collection of entries, and inside such a scope the current
151
+ item is the property's **value** — `$value` and bare names read it — while
152
+ **`$key`** is the property's key:
153
+
154
+ ```js
155
+ const settings = { theme: "dark", fontSize: 14, autosave: true };
156
+ oqx`key: $key, value: $value from entries(${settings})`;
157
+ // [{ key: "theme", value: "dark" }, { key: "fontSize", value: 14 }, { key: "autosave", value: true }]
158
+ oqx`$key values from entries(${settings}) where $value != "dark"`; // ["fontSize", "autosave"]
159
+
160
+ const flags = { beta: { on: true }, legacy: { on: false } };
161
+ oqx`$key values from entries(${flags}) where on`; // ["beta"] (bare `on` reads the value)
162
+
163
+ oqx`name, on: entries(prefs) collect { $key values where $value } from ${users}`; // as a nested receiver
164
+ oqx`name from ${users} where entries(prefs) exists { where $key == "dark" && $value }`;
165
+ ```
166
+
167
+ `entries(array)` yields numeric index keys, a `Map` yields its entries, and
168
+ absence/scalars yield nothing. `$key` exists **only** in an entry scope — an
169
+ ordinary row or array element has no implicit index; `entries(arr)` is how you
170
+ ask for one. As a plain value (not a source) `entries(x)` is an array of
171
+ `{ key, value }` records.
149
172
 
150
173
  ### 3. Predicates (where)
151
174
 
@@ -164,6 +187,14 @@ oqx`name from ${people} where !active`; // → Carol
164
187
  Equality is **typed and strict** (`5 == "5"` is false); a comparison against an
165
188
  absent (`null`/`undefined`) field is simply false rather than an error.
166
189
 
190
+ **String literals** may be double- or single-quoted (`"NYC"` / `'NYC'`) and
191
+ support backslash escapes: `\n`, `\t`, `\r`, `\0`, and `\<any other char>`
192
+ for that character itself (`\"`, `\'`, `\\`). An unterminated string is a lex
193
+ error. Note that inside the `oqx` tagged template JavaScript resolves its own
194
+ escapes first (the template's cooked strings are what OQX lexes), so `\\n` in
195
+ your source reaches OQX as `\n`. Usually you don't need any of this —
196
+ interpolate the host value instead (`where name == ${name}`).
197
+
167
198
  **Ranges.** A Ruby-style range `lo..hi` (inclusive) or `lo...hi` (exclusive high
168
199
  end) is a value, used most often as the right side of `in`. Either bound may be
169
200
  omitted for an open-ended range (`..hi`, `lo..`). Bounds compare with the same
@@ -257,13 +288,28 @@ like a row property.
257
288
 
258
289
  Methods on a value: `contains`, `startsWith`, `endsWith`, `matches` (regex),
259
290
  `size`, `lower`, `upper`. Free functions: `list(x)` (coerce to an array), `size(x)`,
260
- `has(x)`.
291
+ `has(x)`, `range(s)` (§3), `entries(x)` (§2).
261
292
 
262
293
  ```js
263
294
  oqx`name from ${people} where title.startsWith("Eng")`; // → Bob
264
295
  oqx`name from ${people} where title.lower() == "director"`; // → Alice
296
+ oqx`name from ${people} where has(age) && !has(nickname)`; // present vs absent
297
+ oqx`name from ${people} where tags.contains("admin")`; // array membership
265
298
  ```
266
299
 
300
+ - `has(x)` is true when `x` is **present** — anything other than `null` /
301
+ `undefined`. `has(0)`, `has("")`, and `has(false)` are all true; use bare
302
+ truthiness (`where active`) when you mean truthy.
303
+ - `.contains(v)` works on strings (substring) **and arrays** (an element equal
304
+ to `v` under OQX's strict equality); on anything else it is false.
305
+ - `.size()` / `size(x)` is the length of a string or array, the key count of
306
+ an object, and 0 for absent; `.matches(re)` compiles its argument as a
307
+ JavaScript `RegExp`.
308
+
309
+ Anything not in these tables is an **eval error** (`unknown function 'f(…)'` /
310
+ `unknown method '.m(…)'`) — see [custom functions](#custom-functions-and-methods)
311
+ for adding your own.
312
+
267
313
  ### 5. Consumers
268
314
 
269
315
  A consumer shapes a result set. There are six:
@@ -467,10 +513,12 @@ where x in lo..hi / lo...hi / ..hi / lo.. range membership (incl. / excl.
467
513
  where x in range(field) coerce a string field to a range, then test coverage
468
514
  name the CURRENT row's field only (never climbs)
469
515
  $value the current item itself (a scalar row, or the whole object)
516
+ from entries(obj) … $key / $value a record's properties as a collection (key + value; bare names read the value)
470
517
  <expr> values scalar projection: the value, not a { name: value } record
471
518
  ^name / ^^name read an enclosing row's field (exactly N scopes out); ^$value = the enclosing row
472
519
  ^name: expr / ^^name: expr lift/export a value N scopes out (flatten-append)
473
520
  ^rel collect { … } / ^^root exists { … } nested consumer over an enclosing row's relation / a named root
521
+ entries(rel) exists { … } a free-function call may be a receiver
474
522
  order by expr desc, expr2 ordering
475
523
  limit n / offset n bound the row set (after where/order/distinct, before the consumer)
476
524
  follow rel { where … frontier … depth n by … } recursion ($depth/$stop/$leaf/$frontier)
@@ -503,7 +551,7 @@ Query AST ─┬─ InMemoryEngine(DataContext) tier 1/2 — drive any data
503
551
  └─ finishes the residual on the in-memory engine
504
552
  ```
505
553
 
506
- **Everything obeys one scalar-semantics contract** (`oqx.semantics`): typed/strict
554
+ **Everything obeys one scalar-semantics contract** (the `semantics` export): typed/strict
507
555
  equality (`5 == "5"` is false), absent operands make ordering comparisons false,
508
556
  CEL-style `in`, absent-last sort order. Any backend that can't reproduce a rule
509
557
  in its native language must leave that fragment as an in-memory *residual* rather
@@ -531,6 +579,41 @@ const graph = {
531
579
  run(parse("id, depth: $depth from tree follow children"), { context: graph });
532
580
  ```
533
581
 
582
+ #### Custom functions and methods
583
+
584
+ `DataContext` has two optional hooks, `callFunction(name, args)` for free
585
+ functions (`f(x)`) and `callMethod(name, recv, args)` for methods (`x.m()`).
586
+ Each returns a `CallResult`: `{ handled: true, value }` to answer, or
587
+ `{ handled: false }` to decline. **The engine does not consult the builtin
588
+ table itself** — `DefaultContext` is what does that. So a context that omits
589
+ these hooks, or handles only its own names without deferring, loses
590
+ `entries()`, `size()`, `has()`, `range()`, `.contains()`, and the rest: the
591
+ engine throws `OqxError("unknown function 'size(…)'", "eval")`. (The `graph`
592
+ context above has exactly this limitation.) Delegate whatever you don't
593
+ recognize:
594
+
595
+ ```js
596
+ import { DefaultContext, parse, run } from "@omgbase/oqx";
597
+
598
+ const builtins = new DefaultContext(); // or: semantics.BUILTIN_FUNCTIONS[name]
599
+ const ctx = {
600
+ ...graph,
601
+ callFunction(name, args) {
602
+ if (name === "age") return { handled: true, value: yearsSince(args[0]) };
603
+ return builtins.callFunction(name, args); // entries/size/has/range keep working
604
+ },
605
+ callMethod(name, recv, args) {
606
+ if (name === "slug") return { handled: true, value: slugify(recv) };
607
+ return builtins.callMethod(name, recv, args);
608
+ },
609
+ };
610
+ run(parse("id from tree where age(born) > 18 && title.slug() == 'x'"), { context: ctx });
611
+ ```
612
+
613
+ The simplest route is to `extends DefaultContext` and `super.callFunction(...)`
614
+ in the fallthrough. Custom calls are never pushed down by the shipped planners;
615
+ they always run in the residual.
616
+
534
617
  ### Tier 3 — a `QueryPlanner` (pushdown + planning)
535
618
 
536
619
  A planner translates as much of a query as it can into its store's native query
@@ -559,11 +642,130 @@ blocks/nodes, the relations table, `$` intrinsics, `WITH RECURSIVE` for `follow`
559
642
  becomes a `QueryPlanner`, while oqx contributes the parser, IR, semantics
560
643
  contract, and residual executor.
561
644
 
645
+ ## Exports
646
+
647
+ Everything below is exported from `@omgbase/oqx` (`src/index.ts`); the SQLite
648
+ adapter lives on the `@omgbase/oqx/sqlite` subpath.
649
+
650
+ **Running queries**
651
+
652
+ - `oqx` (default export **and** named) — the tagged template. Returns the
653
+ consumer-shaped value: an array for `collect`, a boolean for `exists` / `none`,
654
+ a number for `count`, a record or `null` for `first` / `single`.
655
+ - `parse(source)` — a query string → reusable `Query` AST (an `OqxError` with
656
+ `stage: "lex" | "parse"` on bad input).
657
+ - `execute(source, roots?)` — parse and run a string against named roots
658
+ (`{ people }`), returning the consumer-shaped value.
659
+ - `run(query, opts?)` — run a parsed `Query`, returning the full `OqxResult`.
660
+ `opts.values` are the positional `${…}` bindings; then exactly one of
661
+ `opts.engine` (any `Engine`, e.g. a `PlannedEngine`), `opts.context` (a
662
+ `DataContext`, run on the in-memory engine), or `opts.roots` (plain-object
663
+ named roots → `DefaultContext`). Default: an empty `DefaultContext`.
664
+ - `runQuery(query, values, roots)` — the lower-level call `oqx` / `execute` use:
665
+ in-memory over a `DefaultContext(roots)` (`roots` may be `undefined`).
666
+ - `OqxResult` — the discriminated result `run` returns:
667
+
668
+ ```ts
669
+ type OqxResult =
670
+ | { consumer: "collect"; rows: unknown[] }
671
+ | { consumer: "exists"; exists: boolean }
672
+ | { consumer: "none"; none: boolean }
673
+ | { consumer: "count"; count: number }
674
+ | { consumer: "first"; row: unknown | null }
675
+ | { consumer: "single"; row: unknown | null };
676
+ ```
677
+
678
+ **Errors**
679
+
680
+ - `OqxError` — the one error class for every failure. `error.stage` is
681
+ `"lex" | "parse" | "eval"` so you can branch without matching messages;
682
+ `error.message` carries the detail (position for lex/parse errors, the
683
+ offending name or count for eval errors). Eval errors include: unknown
684
+ function/method, a non-integer or negative `limit` / `offset`, and
685
+ `single { … }` matching **more than one** row (zero rows is `null`, not an
686
+ error — use `first` when zero-or-one is expected and you don't care to assert).
687
+
688
+ **Engines and contexts**
689
+
690
+ - `Engine` — `{ run(query, bindings): OqxResult }`; what `run({ engine })` accepts.
691
+ - `InMemoryEngine` — `new InMemoryEngine(context?)`; the reference engine over a
692
+ `DataContext` (tier 1/2).
693
+ - `DataContext` — the tier-2 interface: `root`, `get`, `toRows`, `identity`,
694
+ plus optional `callFunction` / `callMethod` (above).
695
+ - `CallResult` — `{ handled: boolean; value?: unknown }`, returned by those hooks.
696
+ - `DefaultContext` — `new DefaultContext(roots?)`; plain-object access, `.id`
697
+ identity, and the builtin function/method tables.
698
+ - `semantics` — the scalar-contract module (`equals`, `relate`, `arith`,
699
+ `membership`, `truthy`, `compareForSort`, `sizeOf`, `toList`,
700
+ `coerceCollection`, ranges: `makeRange` / `isRange` / `rangeCovers` /
701
+ `parseRangeString`, entries: `makeEntry` / `isEntry` / `entriesOf`, and the
702
+ `BUILTIN_FUNCTIONS` / `BUILTIN_METHODS` tables). A backend reproducing a rule
703
+ natively must match these.
704
+
705
+ **Planning (tier 3)**
706
+
707
+ - `QueryPlanner` — `{ plan(query, params): Plan | null }`. Return `null` to
708
+ decline a query entirely (full in-memory fallback).
709
+ - `Plan` — `{ rows(): Iterable; residual: Query; context?: DataContext }`: the
710
+ rows the store produced, the query to finish over them, and optionally a
711
+ context for navigating those rows' relations.
712
+ - `PlannedEngine` — `new PlannedEngine(planner, fallbackContext?)`; an `Engine`
713
+ that runs the planner and finishes `plan.residual` on the in-memory engine
714
+ over `plan.rows()` (exposed as the `ROWS_ROOT` root).
715
+ - Planner helpers, used together inside `plan()`: `partitionPushable(where,
716
+ canPush)` splits the top-level `where` conjunction into `pushed` expressions
717
+ (those your `canPush` accepts) and a `residual` where-tree; `asEquality(expr)`
718
+ recognizes `field == const` (either order) as `{ field, value }`;
719
+ `isConst(expr)` / `constValue(expr, params)` tell a literal-or-binding from a
720
+ row-dependent expression and evaluate it against the query bindings; and
721
+ `residualQuery(query, residualWhere)` rebuilds the query to scan `ROWS_ROOT`
722
+ with the pushed predicates dropped and projection / order / consumer / bounds
723
+ intact. A planner typically: checks `query.source` names its table (and
724
+ declines `from` / `follow`), partitions the `where`, translates `pushed` into
725
+ its native query using `constValue` for parameters, then returns
726
+ `{ rows, residual: residualQuery(query, residual) }`.
727
+ - `IndexedCollection` — `new IndexedCollection(rootName, rows, indexFields)`; a
728
+ `QueryPlanner` that hash-indexes `rows` on `indexFields` and answers
729
+ `field == value` predicates on them from the index, leaving the rest residual:
730
+
731
+ ```js
732
+ import { parse, run, IndexedCollection, PlannedEngine } from "@omgbase/oqx";
733
+ const planner = new IndexedCollection("people", people, ["city", "title"]);
734
+ const engine = new PlannedEngine(planner);
735
+ run(parse('name from people where city == "NYC" && age > 30'), { engine });
736
+ // city probe from the index; `age > 30` finished in-memory over the candidates
737
+ ```
738
+
739
+ - `SqliteTable` (from `@omgbase/oqx/sqlite`) —
740
+ `new SqliteTable(db, tableName, options)` over a `node:sqlite` `DatabaseSync`.
741
+ `SqliteTableOptions`:
742
+ - `columns: string[]` — the columns that map to bare OQX fields; only these
743
+ are pushable (any other identifier stays residual).
744
+ - `jsonColumns?: string[]` — columns whose stored text is `JSON.parse`d back
745
+ into the row (for nested relations kept as JSON).
746
+ - `map?: (raw) => row` — a custom raw-SQL-row → query-row mapper (overrides
747
+ `jsonColumns`).
748
+
749
+ **Types**
750
+
751
+ - Every AST node type from `src/ast.ts` is re-exported (`Query`, `Subquery`,
752
+ `Where`, `Expr`, `OpNode`, `SelectItem`, `OrderSpec`, `Follow`, …) for
753
+ planners that walk the IR.
754
+
562
755
  ## Requirements
563
756
 
564
- Node 22.6+ (the sources are TypeScript, run natively via type-stripping — the
565
- package has **no runtime dependencies**). `npm test` runs the suite; `npm run
566
- typecheck` typechecks.
757
+ No runtime dependencies. The package is **ESM-only** (there is no `require`
758
+ condition in `exports`; Node 22.12+ can `require()` an ES module natively).
759
+
760
+ - **Main entry** (`@omgbase/oqx`): compiled ES2022 ESM. `engines.node` says
761
+ `>=22.13.0`, but nothing in the main entry needs it — Node 18+ works in
762
+ practice.
763
+ - **`@omgbase/oqx/sqlite`**: imports `node:sqlite`, which is available without
764
+ a flag from Node 22.13 (behind `--experimental-sqlite` in 22.5–22.12). This is
765
+ the reason for the `engines` floor.
766
+ - **Developing the repo**: `npm test` runs the `.ts` suite directly through
767
+ Node's type stripping, unflagged from Node 22.18 (and all of 24). `npm run
768
+ typecheck` typechecks; `npm run build` emits `dist/`.
567
769
 
568
770
  ## Relationship to omgbase
569
771
 
@@ -571,3 +773,7 @@ This is tier 1 (the in-memory object/collection interpreter) of the OQX
571
773
  implementation tiers. The language kernel here is host-agnostic; richer hosts
572
774
  (e.g. omgbase's docs/blocks/nodes with index pushdown) layer data-model
573
775
  vocabulary and execution capabilities on top of the same surface syntax.
776
+
777
+ ## License
778
+
779
+ [MIT](./LICENSE). Release history is in the [CHANGELOG](./CHANGELOG.md).
package/dist/engine.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare class InMemoryEngine implements Engine {
32
32
  private matches;
33
33
  private rowsOf;
34
34
  private child;
35
+ private enter;
35
36
  private runFollow;
36
37
  private dedupByProjection;
37
38
  private followWalk;
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,KAAK,EACN,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAOhD,yEAAyE;AACzE,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,OAAO,EAAE,CAAA;CAAE,GACxC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACvC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACnC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GAC1C;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;CAC5D;AA0BD,qBAAa,cAAe,YAAW,MAAM;IAC3C,OAAO,CAAC,GAAG,CAAc;gBAEb,OAAO,GAAE,WAAkC;IAIvD,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAE,SAAS,OAAO,EAAO,GAAG,SAAS;IAmD/D,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,KAAK;IAMb,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;IA2BzB,OAAO,CAAC,UAAU;IA6DlB,OAAO,CAAC,KAAK;IAeb,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,SAAS;IAcjB,OAAO,CAAC,WAAW;IAiCnB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAE,aAAa;IAStB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,gBAAgB;IA2BxB,OAAO,CAAC,UAAU;IAwBlB,OAAO,CAAC,QAAQ;IAuDhB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,QAAQ;CAYjB;AAyFD,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,CAG9F"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,KAAK,EACN,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAOhD,yEAAyE;AACzE,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,OAAO,EAAE,CAAA;CAAE,GACxC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACvC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GACnC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,GAC1C;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAAA;CAAE,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,CAAC;CAC5D;AA4BD,qBAAa,cAAe,YAAW,MAAM;IAC3C,OAAO,CAAC,GAAG,CAAc;gBAEb,OAAO,GAAE,WAAkC;IAIvD,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,GAAE,SAAS,OAAO,EAAO,GAAG,SAAS;IAmD/D,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,KAAK;IASb,OAAO,CAAC,KAAK;IAab,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,iBAAiB;IA2BzB,OAAO,CAAC,UAAU;IA6DlB,OAAO,CAAC,KAAK;IAeb,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,SAAS;IAcjB,OAAO,CAAC,WAAW;IAiCnB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,QAAQ;IAKhB,OAAO,CAAE,aAAa;IAStB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,gBAAgB;IA2BxB,OAAO,CAAC,UAAU;IAwBlB,OAAO,CAAC,QAAQ;IAwDhB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,QAAQ;CAYjB;AAyFD,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,CAG9F"}
package/dist/engine.js CHANGED
@@ -13,8 +13,9 @@
13
13
  // fall-through from an inner scope to an outer one — see `resolveIn`.
14
14
  import { DefaultContext } from "./context.js";
15
15
  import { OqxError } from "./errors.js";
16
- import { equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange, } from "./semantics.js";
16
+ import { equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange, isEntry, } from "./semantics.js";
17
17
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
18
+ const KEY = "$key";
18
19
  const HARD_DEPTH_CAP = 8;
19
20
  const UNBOUNDED = { offset: 0, limit: null };
20
21
  export class InMemoryEngine {
@@ -60,7 +61,7 @@ export class InMemoryEngine {
60
61
  : Infinity;
61
62
  let kept = [];
62
63
  for (const r of rows) {
63
- const s = { row: r, parent: root, bindings, lifts: {} };
64
+ const s = this.enter(r, root, { lifts: {} });
64
65
  if (!query.where || this.evalWhere(query.where, s)) {
65
66
  kept.push(s);
66
67
  if (kept.length >= cap)
@@ -97,20 +98,37 @@ export class InMemoryEngine {
97
98
  matches(where, row, parent) {
98
99
  if (!where)
99
100
  return true;
100
- return this.evalWhere(where, { row, parent, bindings: parent.bindings });
101
+ return this.evalWhere(where, this.enter(row, parent));
101
102
  }
102
103
  rowsOf(v) {
103
104
  return Array.from(this.ctx.toRows(v));
104
105
  }
105
106
  child(row, parent) {
106
- return { row, parent, bindings: parent.bindings };
107
+ return this.enter(row, parent);
108
+ }
109
+ // Make the scope for a row. An `entries()` entry is unwrapped here: the scope's
110
+ // row is the property's VALUE (so `$value` and bare names read it) and the key
111
+ // becomes the `$key` intrinsic in `meta`. Every place a row becomes a scope
112
+ // goes through this, so entries behave the same at the top level, in nested
113
+ // blocks, as `from` re-projections, and as follow seeds.
114
+ enter(row, parent, extra = {}) {
115
+ const s = { row, parent, bindings: parent.bindings };
116
+ if (extra.lifts)
117
+ s.lifts = extra.lifts;
118
+ if (extra.meta)
119
+ s.meta = extra.meta;
120
+ if (isEntry(row)) {
121
+ s.row = row.value;
122
+ s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
123
+ }
124
+ return s;
107
125
  }
108
126
  // ---- follow ---------------------------------------------------------------
109
127
  runFollow(query, rows, root, bound) {
110
128
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
111
129
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
112
130
  const occ = this.followWalk(seeds, query.follow, root);
113
- let scopes = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
131
+ let scopes = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
114
132
  if (post)
115
133
  scopes = scopes.filter((s) => this.evalWhere(post, s));
116
134
  this.sortScopes(scopes, query.orderBy);
@@ -151,7 +169,7 @@ export class InMemoryEngine {
151
169
  // `ctx.identity(row)`.
152
170
  followWalk(seedRows, follow, parent) {
153
171
  const cap = follow.depth ?? HARD_DEPTH_CAP;
154
- const scopeFor = (row) => ({ row, parent, bindings: parent.bindings });
172
+ const scopeFor = (row) => this.enter(row, parent);
155
173
  const keyOf = (row) => String(follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(row));
156
174
  const succOf = (row) => {
157
175
  const raw = this.rowsOf(this.evalExpr(follow.receiver, scopeFor(row)));
@@ -309,7 +327,7 @@ export class InMemoryEngine {
309
327
  for (const proj of op.sub.from)
310
328
  rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
311
329
  for (const r of rows) {
312
- const s = { row: r, parent: scope, bindings: scope.bindings, lifts: {} };
330
+ const s = this.enter(r, scope, { lifts: {} });
313
331
  if (!op.sub.where || this.evalWhere(op.sub.where, s))
314
332
  yield s;
315
333
  }
@@ -326,8 +344,8 @@ export class InMemoryEngine {
326
344
  let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
327
345
  for (const proj of sub.from)
328
346
  rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
329
- const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where, { row: r, parent: scope, bindings: scope.bindings, lifts: {} })) : rows;
330
- scopes = this.followWalk(seeds, sub.follow, scope).map((o) => ({ row: o.row, parent: scope, bindings: scope.bindings, meta: o.meta }));
347
+ const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where, this.enter(r, scope, { lifts: {} }))) : rows;
348
+ scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
331
349
  this.sortScopes(scopes, sub.orderBy);
332
350
  if (op.distinct)
333
351
  scopes = this.dedupByProjection(scopes, sub);
@@ -417,9 +435,10 @@ export class InMemoryEngine {
417
435
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
418
436
  // in order: `$value` (the scope's row itself — the current item, whatever its
419
437
  // type, so scalar collections are queryable; absent at the root, which has no
420
- // row); the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
421
- // values lifted into it by `^name:` items; then either the row's own property
422
- // or, for the root scope (no row), the context's named roots.
438
+ // row); `$key` (the property key, for an entry scope only — see `enter`); the
439
+ // recursion intrinsics (`$depth`, …) when it is a follow occurrence; values
440
+ // lifted into it by `^name:` items; then either the row's own property or,
441
+ // for the root scope (no row), the context's named roots.
423
442
  //
424
443
  // A name the scope lacks is simply absent (undefined). It does NOT fall
425
444
  // through to an enclosing scope, so a query's meaning never depends on which
@@ -430,7 +449,7 @@ export class InMemoryEngine {
430
449
  resolveIn(name, scope) {
431
450
  if (name === "$value")
432
451
  return scope.parent === null ? undefined : scope.row;
433
- if (RECUR.has(name))
452
+ if (name === KEY || RECUR.has(name))
434
453
  return scope.meta ? scope.meta[name] : undefined;
435
454
  if (scope.lifts && name in scope.lifts)
436
455
  return scope.lifts[name];