@jarenjs/db 0.72.0 → 0.72.2
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 +6 -4
- package/README.md +12 -8
- package/docs/HOSTS.md +5 -0
- package/docs/MIGRATION-FORMAT.md +33 -0
- package/docs/MODEL-FORMAT.md +12 -3
- package/package.json +4 -4
- package/src/algebra.js +4 -1
- package/src/derive.js +2 -1
- package/src/dialect.js +3 -1
- package/src/dialects/postgres.js +5 -0
- package/src/dialects/sqlite.js +6 -2
- package/src/drivers/postgres.js +15 -4
- package/src/emit.js +4 -0
- package/src/plan.js +40 -4
- package/src/store.js +19 -14
package/ARCHITECTURE.md
CHANGED
|
@@ -258,9 +258,11 @@ re-run unnamed rather than raised.
|
|
|
258
258
|
|
|
259
259
|
Three normalizations, each forced by the wire rather than chosen:
|
|
260
260
|
`int8` and `numeric` arrive as strings (they can exceed a double) and
|
|
261
|
-
become JavaScript numbers
|
|
262
|
-
|
|
263
|
-
|
|
261
|
+
become JavaScript numbers. An `int8` outside the safe integer range
|
|
262
|
+
refuses with `JD2005`; allocated collection inserts decode `RETURNING`
|
|
263
|
+
inside a transaction so a refused key rolls its row back. PostgreSQL
|
|
264
|
+
sequences still advance on rollback. `json`/`jsonb` arrive parsed, because
|
|
265
|
+
the client's type parsers are the host's configuration, and the row decoder reads text; and a
|
|
264
266
|
JavaScript boolean is bound as 1 or 0, because a boolean member is 1 or
|
|
265
267
|
0 in this mapping.
|
|
266
268
|
|
|
@@ -529,7 +531,7 @@ member as an array or an object **and nothing else** — §8.14 answers
|
|
|
529
531
|
| `{$le\|$lt: [{$distance: [<path>, <literal>]}, r]}` | the same four comparisons against `circleBounds(probe, r)` | implied | every position within `r` metres lies inside the circle's box, which the kernel computes on the same sphere and the same `EARTH_RADIUS` the engine measures with — so the two cannot disagree by model. `$ge`/`$gt` is NOT promoted: no box narrows "farther than r" |
|
|
530
532
|
| `{$starts-with: [{$geohash: [<path>, k]}, "<cell>"]}`, cell length ≤ k | `<c> IN ("<cell>")` or `<c> >= "<cell>" AND <c> < successor` | **exact** | the column HOLDS `$geohash(row, k)`, and geohash is a prefix code, so a prefix test on the expression is the same test on the column |
|
|
531
533
|
| the same, cell length > k | the cell truncated to k | implied | the column can only confirm its own first k characters |
|
|
532
|
-
| `{$exists: {$index-of: [{$geohash-neighbours: "<cell>"}, {$geohash: [<path>, k]}]}}`, cell length = k | `<c> IN (…the nine cells…)` | **
|
|
534
|
+
| `{$exists: {$index-of: [{$geohash-neighbours: "<cell>"}, {$geohash: [<path>, k]}]}}`, cell length = k | `<c> IS NULL OR <c> IN (…the nine cells…)` | **inexact** | Unbounded members remain candidates so the row predicate preserves the empty-search-item error. An explicit `$exists` guard excludes them safely. Nine cells cover neighbours across a cell boundary; one prefix does not. |
|
|
533
535
|
|
|
534
536
|
**The proof is a proof about BOXES, not about SQL**, so the physical
|
|
535
537
|
mapping (MODEL-FORMAT §2.1, `physical`) does not enter it: the same box
|
package/README.md
CHANGED
|
@@ -297,7 +297,9 @@ no continuation to emit.
|
|
|
297
297
|
`$within`, a `$bbox-intersects`, a bounded `$distance` or a geohash
|
|
298
298
|
probe over such a collection narrows **in SQLite** through the index
|
|
299
299
|
and refines **in the engine**. `$bbox-intersects` and a geohash cell
|
|
300
|
-
test are exact and need no refinement;
|
|
300
|
+
prefix test are exact and need no refinement; neighbourhood membership
|
|
301
|
+
retains unbounded candidates and preserves the engine's empty-item error.
|
|
302
|
+
`$within` and a bounded
|
|
301
303
|
`$distance` push a bounding box the truth table proves they imply,
|
|
302
304
|
and the exact predicate re-runs over the narrowed candidates —
|
|
303
305
|
`explain().prefilters` says which, over what columns, and whether it
|
|
@@ -431,8 +433,10 @@ how.scanNarrative;
|
|
|
431
433
|
The region arrives as a bound parameter: a GeoJSON object is not a
|
|
432
434
|
value any database can bind, so what binds is one edge of its box per
|
|
433
435
|
slot, computed at bind time from the same kernel the stored columns
|
|
434
|
-
came from. `$bbox-intersects` and a geohash
|
|
435
|
-
|
|
436
|
+
came from. `$bbox-intersects` and a geohash prefix no longer than the
|
|
437
|
+
indexed cell are exact and need no refinement; neighbourhood membership
|
|
438
|
+
refines to preserve errors on
|
|
439
|
+
unbounded candidates. `$within` and a bounded `$distance` push the box
|
|
436
440
|
they provably imply and re-run the exact predicate over the narrowed
|
|
437
441
|
candidates. A circle that reaches a pole or crosses the antimeridian
|
|
438
442
|
pushes **nothing** — there is no single box to push — and the answer is
|
|
@@ -1085,13 +1089,13 @@ disagree.
|
|
|
1085
1089
|
<!--fact:live.eventTimeTable-->
|
|
1086
1090
|
| view | maintained | re-run | ratio |
|
|
1087
1091
|
|---|---:|---:|---:|
|
|
1088
|
-
| bucket (60 s ladder, mean), 1000 rows |
|
|
1089
|
-
| rolling (5 min window, mean), 1000 rows |
|
|
1090
|
-
| bucket (60 s ladder, mean), 10000 rows | 136 µs |
|
|
1091
|
-
| rolling (5 min window, mean), 10000 rows | 13.
|
|
1092
|
+
| bucket (60 s ladder, mean), 1000 rows | 123 µs | 911 µs | 7.4× |
|
|
1093
|
+
| rolling (5 min window, mean), 1000 rows | 411 µs | 3.67 ms | 8.9× |
|
|
1094
|
+
| bucket (60 s ladder, mean), 10000 rows | 136 µs | 8.12 ms | 59.6× |
|
|
1095
|
+
| rolling (5 min window, mean), 10000 rows | 13.4 ms | 60.9 ms | 4.5× |
|
|
1092
1096
|
<!--/fact-->
|
|
1093
1097
|
|
|
1094
|
-
The gain is <!--fact:live.eventTimeBand-->
|
|
1098
|
+
The gain is <!--fact:live.eventTimeBand-->59.6× for the bucket and 4.5× for the rolling at 10,000 readings<!--/fact-->. A bucket
|
|
1095
1099
|
view is nearly flat in the series length, because a write folds one
|
|
1096
1100
|
bucket again and the rest of the ladder is untouched. A rolling view is
|
|
1097
1101
|
not, and the table says so: its answer is one row per reading, so the
|
package/docs/HOSTS.md
CHANGED
|
@@ -6,6 +6,11 @@ capabilities are observed when the connection opens. The common oracle and
|
|
|
6
6
|
lifecycle corpus is `test/db/store-hosts.test.js`; it checks values and coded
|
|
7
7
|
errors across Node, worker, pool, wasm sessions and wasm journal fallback.
|
|
8
8
|
|
|
9
|
+
Concurrent PostgreSQL first opens can race while creating the same collection
|
|
10
|
+
or entity table. Each initialization transaction retries one catalog-creation
|
|
11
|
+
collision after rollback, then re-reads and verifies the winning shape. Ordinary
|
|
12
|
+
constraint failures remain errors, and a repeated collision refuses the open.
|
|
13
|
+
|
|
9
14
|
## Node workers
|
|
10
15
|
|
|
11
16
|
```js
|
package/docs/MIGRATION-FORMAT.md
CHANGED
|
@@ -107,6 +107,39 @@ and the column entry carries the width the value is packed to:
|
|
|
107
107
|
|
|
108
108
|
## 3. Planning and the widening/narrowing rule
|
|
109
109
|
|
|
110
|
+
### Repairing a legacy spatial member expression
|
|
111
|
+
|
|
112
|
+
SQLite derived spatial columns read members as JSON values, preserving strings
|
|
113
|
+
and booleans as well as geometry objects and coordinate arrays. A legacy column
|
|
114
|
+
declared with `json(jsonb_extract(...))` has different SQL and remains a
|
|
115
|
+
`JD0002` at open; opening never rewrites an existing table. Rebuild those
|
|
116
|
+
derived columns through an ordinary migration. The intermediate model below
|
|
117
|
+
removes only spatial indexes; the two plans are combined into one transaction,
|
|
118
|
+
including any R\*Tree trigger removal and backfill:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
const withoutSpatial = structuredClone(model);
|
|
122
|
+
for (const collection of Object.values(withoutSpatial.collections ?? {})) {
|
|
123
|
+
collection.indexes = (collection.indexes ?? [])
|
|
124
|
+
.filter((index) => !['geohash', 'bbox'].includes(index.derive));
|
|
125
|
+
}
|
|
126
|
+
const remove = planMigration(model, withoutSpatial, { dialect: sqliteDialect }).migration;
|
|
127
|
+
const restore = planMigration(withoutSpatial, model, { dialect: sqliteDialect }).migration;
|
|
128
|
+
const repair = {
|
|
129
|
+
...remove, id: 'spatial-member-json', to: restore.to,
|
|
130
|
+
steps: [...remove.steps, ...restore.steps],
|
|
131
|
+
};
|
|
132
|
+
await migrate({ driver, path }, [...previousMigrations, repair], { baseline, model });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Keep the historical baseline and full migration list. With no previous migrations,
|
|
136
|
+
`baseline` is `model`. The logical model hash stays the same; the explicit
|
|
137
|
+
artifact changes the physical expression. Shadow verification and final shape
|
|
138
|
+
validation run normally, stored documents remain intact, and replay skips the
|
|
139
|
+
recorded repair. The stored-column mapping does not need this repair.
|
|
140
|
+
|
|
141
|
+
### Model differences
|
|
142
|
+
|
|
110
143
|
`planMigration(fromModel, toModel, { dialect, id, derived })` produces
|
|
111
144
|
`{ migration, report }` by diffing the two models' PHYSICAL plans. The
|
|
112
145
|
from-model is the previous model — the previous model FILE, or, under
|
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -379,7 +379,7 @@ rather than degrading silently.
|
|
|
379
379
|
|
|
380
380
|
| capability | value | mapping | drivers |
|
|
381
381
|
|---|---|---|---|
|
|
382
|
-
| `deterministicIndexableFunctions` | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(
|
|
382
|
+
| `deterministicIndexableFunctions` | `true` | a **virtual generated column** whose expression calls a deterministic function the store registers at open — `jaren_geohash(<member>, <precision>)`, `jaren_bbox_w(<member>)`, … over `json(("doc" -> '<path>'))` | `node`, `wasm` |
|
|
383
383
|
| `deterministicIndexableFunctions` | `false` | a **stored column** the store writes on every insert, upsert and patch, computed in JavaScript from the same kernel call | `bun` |
|
|
384
384
|
| `rtree` | `false` | a `derive: 'bbox'` column set that declared `physical: 'rtree'` (§2.1) is planned, created and verified as the **B-tree over its four columns**, and `explain().prefilters[].via` reports `'columns'` beside `store.capabilities.rtree === false` | any build without `ENABLE_RTREE` |
|
|
385
385
|
|
|
@@ -451,9 +451,18 @@ no positions at all, or one whose coordinates are not positions, which
|
|
|
451
451
|
is what a non-finite coordinate becomes: JSON cannot carry `NaN`, so it
|
|
452
452
|
arrives as `null` and is no longer a number.
|
|
453
453
|
|
|
454
|
+
A string also has no position: an untyped member can store WKT or even the
|
|
455
|
+
string `"[1,2]"` without interpreting it as a coordinate array. Virtual and
|
|
456
|
+
stored columns both derive `NULL`. Existing SQLite expressions that passed raw
|
|
457
|
+
SQL strings to `json()` need the explicit column repair described in
|
|
458
|
+
[MIGRATION-FORMAT §3](MIGRATION-FORMAT.md#repairing-a-legacy-spatial-member-expression).
|
|
459
|
+
|
|
454
460
|
The consequence is stated here rather than discovered later: **a row
|
|
455
|
-
whose derived column is `NULL` is not found by
|
|
456
|
-
that column.**
|
|
461
|
+
whose derived column is `NULL` is not found by ordinary predicates pushed to
|
|
462
|
+
that column.** Neighbourhood membership retains those rows as candidates and
|
|
463
|
+
re-runs its predicate: `$index-of` raises for an empty search item, so discarding
|
|
464
|
+
the row would hide that error. Guard the geohash with `$exists` before the
|
|
465
|
+
membership test when unbounded rows should be excluded. For the other spatial predicates the planner promotes
|
|
457
466
|
(ARCHITECTURE.md, "The implied conjunct") that is not a divergence —
|
|
458
467
|
§8.14 measures a value by its representative position, and that
|
|
459
468
|
position is missing in exactly the cases the box is, so `$within`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jarenjs/db",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.72.
|
|
4
|
+
"version": "0.72.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"types": "./types/index.d.ts",
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
"prepack": "npm run build:types"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@jarenjs/core": "^0.72.
|
|
88
|
-
"@jarenjs/json": "^0.72.
|
|
89
|
-
"@jarenjs/validate": "^0.72.
|
|
87
|
+
"@jarenjs/core": "^0.72.2",
|
|
88
|
+
"@jarenjs/json": "^0.72.2",
|
|
89
|
+
"@jarenjs/validate": "^0.72.2"
|
|
90
90
|
},
|
|
91
91
|
"bin": {
|
|
92
92
|
"jaren-db": "./src/cli.js"
|
package/src/algebra.js
CHANGED
|
@@ -57,7 +57,7 @@ export const PLAN_VERSION = 2;
|
|
|
57
57
|
* { p: 'udf', name: string, key: string } |
|
|
58
58
|
* { p: 'bboxOverlap', columns: { w: string, s: string, e: string,
|
|
59
59
|
* n: string }, probe: { box: number[] } | { ext: string } } |
|
|
60
|
-
* { p: 'cellIn', column: string, cells: string[] } |
|
|
60
|
+
* { p: 'cellIn', column: string, cells: string[], keepEmpty?: boolean } |
|
|
61
61
|
* { p: 'cellPrefix', column: string, prefix: string } |
|
|
62
62
|
* { p: 'interval', columns: { start: string, end: string },
|
|
63
63
|
* probe: { from: number, to: number } } |
|
|
@@ -75,6 +75,9 @@ export const PLAN_VERSION = 2;
|
|
|
75
75
|
* — but each is TOTAL through its own `IS NOT NULL`, so a row with no
|
|
76
76
|
* box or no cell answers FALSE rather than SQL's NULL and negation
|
|
77
77
|
* still composes classically.
|
|
78
|
+
* A neighbourhood membership uses `keepEmpty: true` instead: NULL
|
|
79
|
+
* cells remain candidates so its residual preserves the query's error
|
|
80
|
+
* for an empty search item. Such a predicate is never exact.
|
|
78
81
|
*
|
|
79
82
|
* `colCmp` is the same idea one comparison wide: a bound over a
|
|
80
83
|
* DECLARED column, with no `json_type` beside it. The planner builds
|
package/src/derive.js
CHANGED
|
@@ -257,7 +257,8 @@ export function memberAt(doc, segments) {
|
|
|
257
257
|
/**
|
|
258
258
|
* Parse the JSON text a derived column's expression hands the function.
|
|
259
259
|
* SQLite passes SQL NULL for a member the document does not have, and
|
|
260
|
-
*
|
|
260
|
+
* the JSON-valued extraction preserves the member's encoding otherwise,
|
|
261
|
+
* including quotes around strings and the spelling of booleans.
|
|
261
262
|
* @param {any} text
|
|
262
263
|
* @returns {any} the value, or `undefined` when there is none
|
|
263
264
|
*/
|
package/src/dialect.js
CHANGED
|
@@ -137,6 +137,7 @@ function normalizeCapabilities(declared) {
|
|
|
137
137
|
* limitClause: (limit: number, offset?: number) => string,
|
|
138
138
|
* jsonPathText: (segments: JsonPathSegment[]) => string | null,
|
|
139
139
|
* jsonExtract: (columnSql: string, pathText: string, kind?: string) => string,
|
|
140
|
+
* isCreateRace?: (error: any) => boolean,
|
|
140
141
|
* derivedExpression?: (memberSql: string, column: { derive: string,
|
|
141
142
|
* precision?: number, component?: string, dims?: number }) => string,
|
|
142
143
|
* jsonSet: (exprSql: string, pathText: string, valueSql: string) => string,
|
|
@@ -532,6 +533,7 @@ export function createDialect(spec) {
|
|
|
532
533
|
limitClause: spec.limitClause,
|
|
533
534
|
jsonPathText: spec.jsonPathText,
|
|
534
535
|
jsonExtract: spec.jsonExtract,
|
|
536
|
+
isCreateRace: spec.isCreateRace,
|
|
535
537
|
/**
|
|
536
538
|
* The expression a DERIVED column is generated from: the member at
|
|
537
539
|
* the index path, as JSON text, handed to the deterministic
|
|
@@ -542,7 +544,7 @@ export function createDialect(spec) {
|
|
|
542
544
|
* @returns {string}
|
|
543
545
|
*/
|
|
544
546
|
derivedColumn: (docColumnSql, pathText, column) => spec.derivedExpression(
|
|
545
|
-
spec.jsonText(spec.jsonExtract(docColumnSql, pathText)), column),
|
|
547
|
+
spec.jsonText(spec.jsonExtract(docColumnSql, pathText, 'json')), column),
|
|
546
548
|
jsonSet: spec.jsonSet,
|
|
547
549
|
jsonRemove: spec.jsonRemove,
|
|
548
550
|
jsonAppend: spec.jsonAppend,
|
package/src/dialects/postgres.js
CHANGED
|
@@ -442,6 +442,11 @@ export function postgresDialect(options = undefined) {
|
|
|
442
442
|
: `LIMIT ${limit === null ? 'ALL' : limit}`),
|
|
443
443
|
jsonPathText,
|
|
444
444
|
jsonExtract,
|
|
445
|
+
// Concurrent first opens can both observe an absent relation before
|
|
446
|
+
// either CREATE commits. Ordinary unique violations remain failures.
|
|
447
|
+
isCreateRace: (error) => error?.code === '42P07' || error?.code === '23505'
|
|
448
|
+
&& (error.constraint === 'pg_type_typname_nsp_index' && error.table === 'pg_type'
|
|
449
|
+
|| error.constraint === 'pg_class_relname_nsp_index' && error.table === 'pg_class'),
|
|
445
450
|
// a DERIVED column's expression names a function the HOST supplies;
|
|
446
451
|
// this dialect creates none and assumes none. Every driver this
|
|
447
452
|
// package ships for PostgreSQL declares
|
package/src/dialects/sqlite.js
CHANGED
|
@@ -299,8 +299,12 @@ export const sqliteDialect = createDialect({
|
|
|
299
299
|
? `LIMIT ${limit === null ? -1 : limit} OFFSET ${offset}`
|
|
300
300
|
: `LIMIT ${limit === null ? -1 : limit}`),
|
|
301
301
|
jsonPathText,
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
// JSON-valued consumers need the encoded member, including quotes around
|
|
303
|
+
// strings and the original boolean spelling; jsonb_extract returns SQL
|
|
304
|
+
// scalars for these and json() cannot recover their JSON representation.
|
|
305
|
+
jsonExtract: (columnSql, pathText, kind) => kind === 'json'
|
|
306
|
+
? `(${columnSql} -> ${stringLiteral(pathText)})`
|
|
307
|
+
: `jsonb_extract(${columnSql}, ${stringLiteral(pathText)})`,
|
|
304
308
|
// a DERIVED column's expression: the member as JSON text handed to
|
|
305
309
|
// the deterministic function the store registers at open. The
|
|
306
310
|
// precision is a LITERAL, not a parameter — a generated column's
|
package/src/drivers/postgres.js
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* - `int8` and `numeric` arrive as STRINGS, because they can exceed
|
|
22
22
|
* what a double holds. The store's contract is JavaScript numbers —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* so they are converted. An `int8` outside the safe integer range
|
|
24
|
+
* refuses rather than returning a rounded key or count.
|
|
25
25
|
* - `json`/`jsonb` arrive PARSED, because the client's type parsers
|
|
26
26
|
* are the host's configuration. Every document read is already
|
|
27
27
|
* `::text` (the dialect's `jsonText`), but a graph load's built
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
import { chain, openConnection, baseCapabilities } from '../driver.js';
|
|
36
36
|
import { postgresDialect } from '../dialects/postgres.js';
|
|
37
|
-
import { DbCompileError } from '../errors.js';
|
|
37
|
+
import { DbCompileError, DbRuntimeError } from '../errors.js';
|
|
38
38
|
|
|
39
39
|
export { postgresDialect, IDENTIFIER_BYTES } from '../dialects/postgres.js';
|
|
40
40
|
|
|
@@ -45,7 +45,7 @@ export const POSTGRES_FLOOR = 160000;
|
|
|
45
45
|
/** Types the wire hands back as text because they can exceed a double,
|
|
46
46
|
* and the two it hands back as text for width alone. The store's
|
|
47
47
|
* contract is JavaScript numbers throughout. */
|
|
48
|
-
const NUMERIC_OIDS = new Set([
|
|
48
|
+
const NUMERIC_OIDS = new Set([21, 23, 26, 700, 701, 1700]);
|
|
49
49
|
/** `json` and `jsonb`: parsed by the client unless the host said
|
|
50
50
|
* otherwise, and the row decoder reads text. */
|
|
51
51
|
const JSON_OIDS = new Set([114, 3802]);
|
|
@@ -73,6 +73,17 @@ let statementSequence = 0;
|
|
|
73
73
|
* is already what the store reads
|
|
74
74
|
*/
|
|
75
75
|
function converterFor(dataTypeID) {
|
|
76
|
+
if (dataTypeID === 20) {
|
|
77
|
+
return (value) => {
|
|
78
|
+
if (value === null || value === undefined) return value;
|
|
79
|
+
const number = Number(value);
|
|
80
|
+
if (!Number.isSafeInteger(number)) {
|
|
81
|
+
throw new DbRuntimeError('JD2005',
|
|
82
|
+
`PostgreSQL int8 value '${String(value)}' is outside the safe JavaScript integer range`);
|
|
83
|
+
}
|
|
84
|
+
return number;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
76
87
|
if (NUMERIC_OIDS.has(dataTypeID)) {
|
|
77
88
|
return (value) => (value === null || value === undefined ? value : Number(value));
|
|
78
89
|
}
|
package/src/emit.js
CHANGED
|
@@ -393,6 +393,10 @@ export function emitPlan(plan, dialect, physical) {
|
|
|
393
393
|
// per row.
|
|
394
394
|
const column = q(pred.column);
|
|
395
395
|
const list = pred.cells.map((cell) => param({ literal: cell })).join(', ');
|
|
396
|
+
// An empty membership search item raises in the query engine. The
|
|
397
|
+
// candidate fetch must retain it for the original row predicate.
|
|
398
|
+
if (pred.keepEmpty === true) return pred.cells.length === 0 ? `${column} IS NULL`
|
|
399
|
+
: `(${column} IS NULL OR ${column} IN (${list}))`;
|
|
396
400
|
return `(${column} IS NOT NULL AND ${column} IN (${list}))`;
|
|
397
401
|
}
|
|
398
402
|
case 'cellPrefix': {
|
package/src/plan.js
CHANGED
|
@@ -505,6 +505,8 @@ const SPATIAL_REASONS = {
|
|
|
505
505
|
distance: 'a geodesic-circle box pre-filter is pushed; the exact distance refines in the engine',
|
|
506
506
|
prefix: "a cell-range pre-filter over the derived column's precision is pushed; "
|
|
507
507
|
+ 'the longer prefix refines in the engine',
|
|
508
|
+
membership: 'the cell pre-filter retains unbounded members; the engine preserves '
|
|
509
|
+
+ 'the membership error when its search item is empty',
|
|
508
510
|
noIndex: 'no derived spatial index on this member covers the predicate '
|
|
509
511
|
+ '(declare indexes[].derive on it)',
|
|
510
512
|
notGeographic: 'spatial predicates translate only over a member the schema types as an '
|
|
@@ -856,10 +858,12 @@ function planCellPrefix(node, itSlot, shape) {
|
|
|
856
858
|
* the cells inline, where the planner can see them.
|
|
857
859
|
*
|
|
858
860
|
* Recognized: `{$exists: {$index-of: [{$geohash-neighbours: "cell"},
|
|
859
|
-
* {$geohash: [path, k]}]}}`.
|
|
861
|
+
* {$geohash: [path, k]}]}}`. Admitted only when the cell's length IS k —
|
|
860
862
|
* the membership test compares whole strings, so any other length makes
|
|
861
863
|
* the document's own predicate constantly false and the promotion would
|
|
862
|
-
* be answering a different question.
|
|
864
|
+
* be answering a different question. Unbounded rows remain candidates:
|
|
865
|
+
* the engine's membership raises on an empty search item, so the original
|
|
866
|
+
* predicate must still run even when the column covers whole cell strings.
|
|
863
867
|
* @param {any} node
|
|
864
868
|
* @param {number} itSlot
|
|
865
869
|
* @param {any} shape
|
|
@@ -878,9 +882,26 @@ function planCellNeighbourhood(node, itSlot, shape) {
|
|
|
878
882
|
const cells = cellNeighbourhood(cell.value);
|
|
879
883
|
if (cells.length === 0)
|
|
880
884
|
return { refusal: refusal('$geohash-neighbours', SPATIAL_REASONS.operand) };
|
|
881
|
-
return promotion({ p: 'cellIn', column: derivation.column, cells },
|
|
885
|
+
return promotion({ p: 'cellIn', column: derivation.column, cells, keepEmpty: true },
|
|
882
886
|
{ construct: '$geohash-neighbours', via: 'columns', columns: [derivation.column],
|
|
883
|
-
exact:
|
|
887
|
+
exact: false }, [refusal('$geohash-neighbours', SPATIAL_REASONS.membership)]);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Error candidates cannot be discarded by a different pushed conjunct. */
|
|
891
|
+
function neighbourhoodErrorColumns(node, itSlot, shape, columns = new Set()) {
|
|
892
|
+
if (node === null || typeof node !== 'object') return columns;
|
|
893
|
+
if (node.kind === 'op' && node.name === '$index-of'
|
|
894
|
+
&& node.args[0]?.name === '$geohash-neighbours') {
|
|
895
|
+
const derivation = geohashDerivation(node.args[1], itSlot, shape, '$geohash-neighbours');
|
|
896
|
+
if (!('refusal' in derivation)) columns.add(derivation.column);
|
|
897
|
+
}
|
|
898
|
+
for (const value of Object.values(node)) {
|
|
899
|
+
if (Array.isArray(value)) {
|
|
900
|
+
for (const child of value) neighbourhoodErrorColumns(child, itSlot, shape, columns);
|
|
901
|
+
}
|
|
902
|
+
else if (value !== null && typeof value === 'object') neighbourhoodErrorColumns(value, itSlot, shape, columns);
|
|
903
|
+
}
|
|
904
|
+
return columns;
|
|
884
905
|
}
|
|
885
906
|
|
|
886
907
|
// ————— The k-nearest promotion: an ORDERING the column pre-filters —————
|
|
@@ -2365,6 +2386,21 @@ function planFlwor(node, shape, rawFlwor, udfHook) {
|
|
|
2365
2386
|
}
|
|
2366
2387
|
}
|
|
2367
2388
|
|
|
2389
|
+
// A later conjunct must not hide an earlier membership error. Retain
|
|
2390
|
+
// every potentially empty search row through the combined SQL filter;
|
|
2391
|
+
// the original WHERE then decides whether short-circuiting reaches it.
|
|
2392
|
+
const errorColumns = neighbourhoodErrorColumns(node.where, itSlot, shape);
|
|
2393
|
+
if (errorColumns.size > 0 && plan.filter !== null) {
|
|
2394
|
+
if (!(plan.filter.p === 'cellIn' && plan.filter.keepEmpty === true
|
|
2395
|
+
&& errorColumns.size === 1 && errorColumns.has(plan.filter.column))) {
|
|
2396
|
+
plan.filter = { p: 'or', items: [plan.filter, ...[...errorColumns].map((column) =>
|
|
2397
|
+
({ p: 'cellIn', column, cells: [], keepEmpty: true }))] };
|
|
2398
|
+
}
|
|
2399
|
+
whereFullyPushed = false;
|
|
2400
|
+
if (!reasons.some((entry) => entry.reason === SPATIAL_REASONS.membership))
|
|
2401
|
+
reasons.push(refusal('$geohash-neighbours', SPATIAL_REASONS.membership));
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2368
2404
|
// ORDER BY: all terms or none — a partially pushed ordering is wrong.
|
|
2369
2405
|
// A k-nearest ordering is the third outcome: not pushed, but
|
|
2370
2406
|
// recognized for the column to pre-filter (the reason is the
|
package/src/store.js
CHANGED
|
@@ -450,12 +450,19 @@ function wrapWriteError(error, plan, collection, docPath, key) {
|
|
|
450
450
|
* open nothing else holds the connection.
|
|
451
451
|
* @param {any} connection
|
|
452
452
|
* @param {() => any} fn - value-or-promise
|
|
453
|
+
* @param {boolean} [retry] - one retry for a concurrent catalog creation
|
|
453
454
|
* @returns {any} value-or-promise
|
|
454
455
|
*/
|
|
455
|
-
function immediately(connection, fn) {
|
|
456
|
+
function immediately(connection, fn, retry = true) {
|
|
456
457
|
const dialect = connection.dialect;
|
|
457
458
|
const commit = (value) => chain(connection.exec(dialect.tx.commit), () => value);
|
|
458
|
-
const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => {
|
|
459
|
+
const rollback = (error) => chain(connection.exec(dialect.tx.rollback), () => {
|
|
460
|
+
// The winner's CREATE has committed before a catalog collision returns.
|
|
461
|
+
// Re-read and verify that shape in a fresh transaction, once per bracket;
|
|
462
|
+
// collection and entity creation can race independently during one open.
|
|
463
|
+
if (retry && dialect.isCreateRace?.(error) === true) return immediately(connection, fn, false);
|
|
464
|
+
throw error;
|
|
465
|
+
});
|
|
459
466
|
return chain(connection.exec(dialect.tx.beginImmediate), () => {
|
|
460
467
|
let out;
|
|
461
468
|
try {
|
|
@@ -673,6 +680,14 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
673
680
|
(error) => wrapWriteError(error, plan, collection.name, collection.docPath, key)));
|
|
674
681
|
};
|
|
675
682
|
|
|
683
|
+
// RETURNING is decoded after the server has inserted the row. Keep
|
|
684
|
+
// decoding in the same transaction so an unrepresentable allocated
|
|
685
|
+
// key refuses without committing a document the caller cannot address.
|
|
686
|
+
const insertAllocated = (doc) => connection.transaction(() => chain(
|
|
687
|
+
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
688
|
+
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
689
|
+
(row) => row.key));
|
|
690
|
+
|
|
676
691
|
const core = {
|
|
677
692
|
stats: () => ({ ...stats, ...engine.stats() }),
|
|
678
693
|
model: collection,
|
|
@@ -694,12 +709,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
694
709
|
insert(doc) {
|
|
695
710
|
checkValid(doc);
|
|
696
711
|
const key = resolveWriteKey(doc, undefined);
|
|
697
|
-
if (key === null)
|
|
698
|
-
return chain(
|
|
699
|
-
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
700
|
-
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
701
|
-
(row) => row.key);
|
|
702
|
-
}
|
|
712
|
+
if (key === null) return insertAllocated(doc);
|
|
703
713
|
return chain(
|
|
704
714
|
runWrite('insert', dialect.dml.insert(shape),
|
|
705
715
|
[key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
|
|
@@ -708,12 +718,7 @@ function collectionCore(connection, collection, plan, validate, queryState, stor
|
|
|
708
718
|
put(doc, explicitKey) {
|
|
709
719
|
checkValid(doc);
|
|
710
720
|
const key = resolveWriteKey(doc, explicitKey);
|
|
711
|
-
if (key === null)
|
|
712
|
-
return chain(
|
|
713
|
-
runWrite('insertAllocated', dialect.dml.insertAllocated(shape),
|
|
714
|
-
[JSON.stringify(doc), ...derivedFor(doc)], undefined, true),
|
|
715
|
-
(row) => row.key);
|
|
716
|
-
}
|
|
721
|
+
if (key === null) return insertAllocated(doc);
|
|
717
722
|
return chain(
|
|
718
723
|
runWrite('upsert', dialect.dml.upsert(shape),
|
|
719
724
|
[key, JSON.stringify(doc), ...derivedFor(doc)], key, false),
|