@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 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, which is the same ceiling SQLite's INTEGER
262
- has; `json`/`jsonb` arrive parsed, because the client's type parsers are
263
- the host's configuration, and the row decoder reads text; and a
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…)` | **exact** | the membership test compares whole strings and the column is exactly one of them. Nine cells, never one: two points ten metres apart can differ in the FIRST character of their cell (D7), so a single prefix is bucketing and only the neighbourhood is proximity |
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; `$within` and a bounded
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 cell test are exact and
435
- need no refinement; `$within` and a bounded `$distance` push the box
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 | 119 µs | 1.09 ms | 9.2× |
1089
- | rolling (5 min window, mean), 1000 rows | 407 µs | 3.86 ms | 9.5× |
1090
- | bucket (60 s ladder, mean), 10000 rows | 136 µs | 10.9 ms | 80.2× |
1091
- | rolling (5 min window, mean), 10000 rows | 13.7 ms | 62.7 ms | 4.6× |
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-->80.2× for the bucket and 4.6× for the rolling at 10,000 readings<!--/fact-->. A bucket
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
@@ -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
@@ -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(jsonb_extract("doc", '<path>'))` | `node`, `wasm` |
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 a predicate pushed to
456
- that column.** For the spatial predicates the planner promotes
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.0",
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.0",
88
- "@jarenjs/json": "^0.72.0",
89
- "@jarenjs/validate": "^0.72.0"
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
- * `json()` of an extracted member is unambiguous JSON text otherwise.
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,
@@ -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
@@ -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
- jsonExtract: (columnSql, pathText) =>
303
- `jsonb_extract(${columnSql}, ${stringLiteral(pathText)})`,
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
@@ -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
- * the same ceiling SQLite's INTEGER has so they are converted, and
24
- * a value past 2^53 loses precision here exactly as it would there.
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([20, 21, 23, 26, 700, 701, 1700]);
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]}]}}`. Exact only when the cell's length IS 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: true });
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), () => { throw error; });
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),