@spooky-sync/core 0.0.1-canary.282 → 0.0.1-canary.286
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/dist/index.js +39 -10
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +1 -1
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +9 -6
- package/src/query/fetch.saga.test.ts +44 -1
- package/src/query/fetch.saga.ts +7 -2
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/sqlite-cache-engine.ts +9 -5
- package/src/services/database/sqlite-plan-sql.test.ts +35 -0
- package/src/services/database/sqlite-plan-sql.ts +26 -0
- package/src/utils/parser.test.ts +37 -1
- package/src/utils/parser.ts +32 -0
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as renderOrderSql, c as
|
|
1
|
+
import { a as renderOrderSql, c as serializeMergeRow, d as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as resolveRelations } from "./sqlite-open.js";
|
|
2
2
|
import { QueryBuilder, RecordId, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
|
|
3
3
|
import { DateTime, Duration, RecordId as RecordId$1, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
|
|
4
4
|
import pino from "pino";
|
|
@@ -448,6 +448,34 @@ function cleanRecord(tableSchema, record) {
|
|
|
448
448
|
return cleaned;
|
|
449
449
|
}
|
|
450
450
|
/**
|
|
451
|
+
* The OPTIONAL schema columns a full server row does not carry, each mapped to
|
|
452
|
+
* `undefined`, for spreading into a body that is landed with MERGE.
|
|
453
|
+
*
|
|
454
|
+
* The server omits an optional field that is unset (`UPDATE t UNSET club`
|
|
455
|
+
* leaves no `club` key in `SELECT *` or the SSP body), and MERGE keeps every
|
|
456
|
+
* key the patch does not mention, so without this the local store held on to
|
|
457
|
+
* a cleared value forever. `undefined` is the one representation both engines
|
|
458
|
+
* read as "remove": the SurrealDB SDK encodes it as NONE, which MERGE removes
|
|
459
|
+
* (NULL would be a type error on an `option<record<...>>` field), and the
|
|
460
|
+
* SQLite engine turns it into the `null` member that `json_patch` removes (see
|
|
461
|
+
* `serializeMergeRow`).
|
|
462
|
+
*
|
|
463
|
+
* Required columns are left alone. A SCHEMAFULL server cannot hold NONE in
|
|
464
|
+
* one, so its absence means the row was not readable in full (a field
|
|
465
|
+
* permission), not that it was cleared, and NONE in a typed local store is a
|
|
466
|
+
* coercion error that would fail the whole chunk's transaction. `id` and the
|
|
467
|
+
* local-only `_00_*` bookkeeping are never touched.
|
|
468
|
+
*/
|
|
469
|
+
function absentOptionalColumns(tableSchema, record) {
|
|
470
|
+
const cleared = {};
|
|
471
|
+
if (!tableSchema) return cleared;
|
|
472
|
+
for (const [key, column] of Object.entries(tableSchema)) {
|
|
473
|
+
if (key === "id" || key.startsWith("_00_") || column?.optional !== true) continue;
|
|
474
|
+
if (record[key] === void 0) cleared[key] = void 0;
|
|
475
|
+
}
|
|
476
|
+
return cleared;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
451
479
|
* Parse a RECORD's fields against the table schema. Anything the schema does not
|
|
452
480
|
* know is dropped, which is what keeps a stray field out of a write.
|
|
453
481
|
*/
|
|
@@ -1724,8 +1752,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1724
1752
|
|
|
1725
1753
|
//#endregion
|
|
1726
1754
|
//#region src/modules/devtools/index.ts
|
|
1727
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
1728
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
1755
|
+
const CORE_VERSION = "0.0.1-canary.286";
|
|
1756
|
+
const WASM_VERSION = "0.0.1-canary.286";
|
|
1729
1757
|
const SURREAL_VERSION = "3.0.3";
|
|
1730
1758
|
var DevToolsService = class DevToolsService {
|
|
1731
1759
|
eventsHistory = [];
|
|
@@ -5485,6 +5513,7 @@ function* landChunk(env, requested, rows, versions, state, epoch) {
|
|
|
5485
5513
|
bodies.push({
|
|
5486
5514
|
id: rid,
|
|
5487
5515
|
content: {
|
|
5516
|
+
...absentOptionalColumns(columns, row),
|
|
5488
5517
|
...content,
|
|
5489
5518
|
_00_rv: version
|
|
5490
5519
|
}
|
|
@@ -9310,7 +9339,7 @@ var SqliteCacheEngine = class {
|
|
|
9310
9339
|
await this.ensureTable(table);
|
|
9311
9340
|
const key = stableKey(id);
|
|
9312
9341
|
if (mode === "merge") {
|
|
9313
|
-
const
|
|
9342
|
+
const { insert, patch } = serializeMergeRow({
|
|
9314
9343
|
...data,
|
|
9315
9344
|
id: key
|
|
9316
9345
|
});
|
|
@@ -9318,8 +9347,8 @@ var SqliteCacheEngine = class {
|
|
|
9318
9347
|
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
9319
9348
|
bind: [
|
|
9320
9349
|
key,
|
|
9321
|
-
|
|
9322
|
-
|
|
9350
|
+
insert,
|
|
9351
|
+
patch
|
|
9323
9352
|
]
|
|
9324
9353
|
});
|
|
9325
9354
|
return;
|
|
@@ -9496,7 +9525,7 @@ var SqliteCacheEngine = class {
|
|
|
9496
9525
|
const key = stableKey(op.id);
|
|
9497
9526
|
ensure(t);
|
|
9498
9527
|
if (op.mode === "merge") {
|
|
9499
|
-
const
|
|
9528
|
+
const { insert, patch } = serializeMergeRow({
|
|
9500
9529
|
...op.data,
|
|
9501
9530
|
id: key
|
|
9502
9531
|
});
|
|
@@ -9504,8 +9533,8 @@ var SqliteCacheEngine = class {
|
|
|
9504
9533
|
sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
9505
9534
|
bind: [
|
|
9506
9535
|
key,
|
|
9507
|
-
|
|
9508
|
-
|
|
9536
|
+
insert,
|
|
9537
|
+
patch
|
|
9509
9538
|
]
|
|
9510
9539
|
});
|
|
9511
9540
|
} else stmts.push({
|
|
@@ -13758,7 +13787,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
13758
13787
|
return new TabsCoordinator({
|
|
13759
13788
|
tabId: s.tabId,
|
|
13760
13789
|
fingerprint: computeTabsFingerprint({
|
|
13761
|
-
coreVersion: "0.0.1-canary.
|
|
13790
|
+
coreVersion: "0.0.1-canary.286",
|
|
13762
13791
|
schemaHash: hash53(config.schemaSurql),
|
|
13763
13792
|
endpoint: config.database.endpoint ?? "",
|
|
13764
13793
|
namespace: config.database.namespace,
|
package/dist/sqlite-open.js
CHANGED
|
@@ -182,6 +182,33 @@ function serializeRow(row) {
|
|
|
182
182
|
return v;
|
|
183
183
|
});
|
|
184
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
187
|
+
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
188
|
+
*
|
|
189
|
+
* A top-level own key holding `undefined` means "clear this field", which is
|
|
190
|
+
* what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
|
|
191
|
+
* such a key, so a patch built by {@link serializeRow} alone never mentioned
|
|
192
|
+
* it and the stored value survived every merge: a field cleared on the server
|
|
193
|
+
* stayed set locally. The patch therefore carries each one as a `null` member,
|
|
194
|
+
* which `json_patch` removes; the insert row simply lacks it, exactly as
|
|
195
|
+
* before. Appended to the already-serialized text, so the common case costs no
|
|
196
|
+
* second stringify. Nested `undefined` stays dropped (never a clear here).
|
|
197
|
+
*/
|
|
198
|
+
function serializeMergeRow(row) {
|
|
199
|
+
const insert = serializeRow(row);
|
|
200
|
+
const cleared = [];
|
|
201
|
+
for (const key of Object.keys(row)) if (row[key] === void 0) cleared.push(`${JSON.stringify(key)}:null`);
|
|
202
|
+
if (cleared.length === 0 || insert[0] !== "{") return {
|
|
203
|
+
insert,
|
|
204
|
+
patch: insert
|
|
205
|
+
};
|
|
206
|
+
const members = cleared.join(",");
|
|
207
|
+
return {
|
|
208
|
+
insert,
|
|
209
|
+
patch: insert === "{}" ? `{${members}}` : `${insert.slice(0, -1)},${members}}`
|
|
210
|
+
};
|
|
211
|
+
}
|
|
185
212
|
function reviveRow(json) {
|
|
186
213
|
if (json.indexOf("\"__u8\"") === -1) return JSON.parse(json);
|
|
187
214
|
return JSON.parse(json, (_k, v) => {
|
|
@@ -328,4 +355,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
|
328
355
|
}
|
|
329
356
|
|
|
330
357
|
//#endregion
|
|
331
|
-
export { renderOrderSql as a,
|
|
358
|
+
export { renderOrderSql as a, serializeMergeRow as c, stableKey as d, projectedDataSql as i, serializeRow as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, resolveRelations as u };
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as renderOrderSql,
|
|
1
|
+
import { a as renderOrderSql, d as stableKey, i as projectedDataSql, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as resolveRelations } from "./sqlite-open.js";
|
|
2
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
|
|
4
4
|
//#region src/services/database/sqlite-lock-verify.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.286",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.286",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.286",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.0.3",
|
|
82
82
|
"blurhash": "^2.0.5",
|
|
@@ -182,12 +182,15 @@ Backend runs let you trigger server-side HTTP operations via an **outbox pattern
|
|
|
182
182
|
### `db.run()` Signature
|
|
183
183
|
|
|
184
184
|
```typescript
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
// A method of the client: called as `db.run(backend, route, payload, options)`.
|
|
186
|
+
interface Sp00kyClient<S> {
|
|
187
|
+
run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
188
|
+
backend: B, // Backend name from sp00ky.yml (e.g., 'api')
|
|
189
|
+
route: R, // Route path from OpenAPI spec (e.g., '/spookify')
|
|
190
|
+
payload: RoutePayload<S, B, R>, // Typed args for the route
|
|
191
|
+
options?: RunOptions
|
|
192
|
+
): Promise<void>;
|
|
193
|
+
}
|
|
191
194
|
```
|
|
192
195
|
|
|
193
196
|
All parameters are type-safe — `backend`, `route`, and `payload` are inferred from the generated schema.
|
|
@@ -4,7 +4,7 @@ import { runPure } from '../testing/run-pure';
|
|
|
4
4
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
5
|
import * as R from '../state/reducers';
|
|
6
6
|
import { defaultEnv } from './env';
|
|
7
|
-
import { fetchRows } from './fetch.saga';
|
|
7
|
+
import { fetchRows, landChunk } from './fetch.saga';
|
|
8
8
|
import type { StatementResult } from '../kernel/effects';
|
|
9
9
|
|
|
10
10
|
const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {} } }] } as any);
|
|
@@ -154,3 +154,46 @@ describe('fetchRows', () => {
|
|
|
154
154
|
expect(out.state.versions.get('thing:2')).toBe(1);
|
|
155
155
|
});
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
describe('landChunk', () => {
|
|
159
|
+
const typed = defaultEnv({
|
|
160
|
+
tables: [
|
|
161
|
+
{
|
|
162
|
+
name: 'broadcast',
|
|
163
|
+
columns: {
|
|
164
|
+
title: { type: 'string', optional: false },
|
|
165
|
+
owner: { type: 'string', recordId: true, optional: false },
|
|
166
|
+
club: { type: 'string', recordId: true, optional: true },
|
|
167
|
+
note: { type: 'string', optional: true },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
} as any);
|
|
172
|
+
|
|
173
|
+
// `UPDATE broadcast:x UNSET club` leaves no `club` key in the body the server
|
|
174
|
+
// returns, and a MERGE that does not name it kept the stale local club.
|
|
175
|
+
it('names each optional column the body lacks as undefined so the MERGE clears it', async () => {
|
|
176
|
+
const rid = new RecordId('broadcast', 'x');
|
|
177
|
+
const out = await runPure(landChunk(typed, ['broadcast:x'], [{ id: rid, title: 't', note: 'n', _00_extra: 1 }], new Map([['broadcast:x', 4]]), buildState(), 3), {
|
|
178
|
+
handlers: { 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
179
|
+
});
|
|
180
|
+
expect(out.result).toBe(true);
|
|
181
|
+
const exec = out.log.find((e) => e.kind === 'local.execute') as any;
|
|
182
|
+
expect(exec.query.sql).toContain('UPSERT ONLY $id0 MERGE $content0');
|
|
183
|
+
expect(exec.vars.content0).toStrictEqual({ club: undefined, title: 't', note: 'n', _00_extra: 1, _00_rv: 4 });
|
|
184
|
+
expect(Object.prototype.hasOwnProperty.call(exec.vars.content0, 'club')).toBe(true);
|
|
185
|
+
// A required column (`owner`) is never cleared: its absence means hidden, not unset.
|
|
186
|
+
expect(Object.prototype.hasOwnProperty.call(exec.vars.content0, 'owner')).toBe(false);
|
|
187
|
+
// The circuit record carries no clear: CREATE/UPDATE replace the circuit row.
|
|
188
|
+
const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
|
|
189
|
+
expect(ingest.records[0].record).toStrictEqual({ id: rid, title: 't', note: 'n', _00_extra: 1, _00_rv: 4 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('names nothing for a table without a schema', async () => {
|
|
193
|
+
const out = await runPure(landChunk(typed, ['other:1'], [{ id: new RecordId('other', '1'), z: 1 }], new Map([['other:1', 1]]), buildState(), 0), {
|
|
194
|
+
handlers: { 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
195
|
+
});
|
|
196
|
+
const exec = out.log.find((e) => e.kind === 'local.execute') as any;
|
|
197
|
+
expect(exec.vars.content0).toStrictEqual({ z: 1, _00_rv: 1 });
|
|
198
|
+
});
|
|
199
|
+
});
|
package/src/query/fetch.saga.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ClientState } from '../state/client-state';
|
|
|
8
8
|
import * as R from '../state/reducers';
|
|
9
9
|
import { planFetch } from '../state/selectors';
|
|
10
10
|
import { encodeRecordId, parseRecordIdString } from '../utils/index';
|
|
11
|
-
import { cleanRecord } from '../utils/parser';
|
|
11
|
+
import { absentOptionalColumns, cleanRecord } from '../utils/parser';
|
|
12
12
|
import type { SagaEnv } from './env';
|
|
13
13
|
import * as sql from './sql';
|
|
14
14
|
|
|
@@ -87,7 +87,12 @@ export function* landChunk(
|
|
|
87
87
|
const columns = env.schema.tables.find((t) => t.name === table)?.columns;
|
|
88
88
|
const cleaned = columns ? cleanRecord(columns, row) : row;
|
|
89
89
|
const { id: _id, ...content } = cleaned;
|
|
90
|
-
|
|
90
|
+
// The row is the server's whole body, so an optional column it lacks was
|
|
91
|
+
// cleared upstream: name it (as `undefined`) so the MERGE removes the local
|
|
92
|
+
// copy instead of keeping it. `_00_*` local bookkeeping is never named, so
|
|
93
|
+
// MERGE (not REPLACE) still preserves it. The circuit record below needs no
|
|
94
|
+
// such key: CREATE/UPDATE replace the circuit's row outright.
|
|
95
|
+
bodies.push({ id: rid, content: { ...absentOptionalColumns(columns, row), ...content, _00_rv: version } });
|
|
91
96
|
ingest.push({ table, op: state.versions.has(id) ? 'UPDATE' : 'CREATE', id, record: { ...cleaned, _00_rv: version } });
|
|
92
97
|
seen.add(id);
|
|
93
98
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
5
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
6
|
+
import { landChunk } from '../../query/fetch.saga';
|
|
7
|
+
import { defaultEnv } from '../../query/env';
|
|
8
|
+
import { runPure } from '../../testing/run-pure';
|
|
9
|
+
import { buildState } from '../../testing/build';
|
|
10
|
+
import type { Vars } from '../../kernel/effects';
|
|
11
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
12
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
13
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
14
|
+
import type { Row } from './cache-engine';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Integration: a field cleared on the server (`UPDATE broadcast:x UNSET club`)
|
|
18
|
+
* is ABSENT from the body the server returns, and the body is landed with
|
|
19
|
+
* MERGE, which keeps every key it does not name. These land real bodies
|
|
20
|
+
* through `landChunk` and run the exact transaction it emits on BOTH local
|
|
21
|
+
* engines: a real in-memory SQLite (the worker's @sqlite.org/sqlite-wasm
|
|
22
|
+
* build) behind the SqliteCacheEngine, and a real SurrealDB-WASM `mem://`
|
|
23
|
+
* store provisioned with a typed schema, as the local migrator does.
|
|
24
|
+
*
|
|
25
|
+
* On each: the cleared optional field is gone, local `_00_*` bookkeeping
|
|
26
|
+
* survives (still a MERGE, not a REPLACE), and a required field the body
|
|
27
|
+
* lacks is kept rather than cleared.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const env = defaultEnv({
|
|
31
|
+
tables: [
|
|
32
|
+
{
|
|
33
|
+
name: 'broadcast',
|
|
34
|
+
columns: {
|
|
35
|
+
title: { type: 'string', optional: false },
|
|
36
|
+
secret: { type: 'string', optional: false },
|
|
37
|
+
club: { type: 'string', recordId: true, optional: true },
|
|
38
|
+
note: { type: 'string', optional: true },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
} as any);
|
|
43
|
+
|
|
44
|
+
type Exec = (query: SealedQuery<unknown>, vars: Vars) => Promise<unknown>;
|
|
45
|
+
|
|
46
|
+
/** Land `rows` at `version` through the real saga, executing its write with `exec`. */
|
|
47
|
+
async function land(
|
|
48
|
+
exec: Exec,
|
|
49
|
+
rows: Array<Record<string, unknown>>,
|
|
50
|
+
version: number
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const ids = rows.map((r) => String(r.id));
|
|
53
|
+
const out = await runPure(
|
|
54
|
+
landChunk(env, ids, rows, new Map(ids.map((id) => [id, version] as const)), buildState(), 0),
|
|
55
|
+
{
|
|
56
|
+
handlers: {
|
|
57
|
+
'local.execute': (e: any) => exec(e.query, e.vars),
|
|
58
|
+
'ssp.ingest': () => undefined,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
expect(out.emitted).not.toContainEqual(expect.objectContaining({ message: 'body write failed' }));
|
|
63
|
+
expect(out.result).toBe(true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const x = new RecordId('broadcast', 'x');
|
|
67
|
+
const fresh = new RecordId('broadcast', 'fresh');
|
|
68
|
+
const club = new RecordId('club', 'c1');
|
|
69
|
+
|
|
70
|
+
const noop = () => {};
|
|
71
|
+
|
|
72
|
+
// ==================== SQLite ====================
|
|
73
|
+
|
|
74
|
+
describe('landed body clears an absent optional field: SQLite engine', () => {
|
|
75
|
+
let db: any;
|
|
76
|
+
|
|
77
|
+
function realEngine(): SqliteCacheEngine {
|
|
78
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
79
|
+
logger.child = () => logger;
|
|
80
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
|
|
81
|
+
stubTransport(engine, (type, payload: any) => {
|
|
82
|
+
switch (type) {
|
|
83
|
+
case 'open':
|
|
84
|
+
return { persisted: true };
|
|
85
|
+
case 'exec':
|
|
86
|
+
return {
|
|
87
|
+
rows: db.exec({
|
|
88
|
+
sql: payload.sql,
|
|
89
|
+
bind: payload.bind,
|
|
90
|
+
rowMode: 'object',
|
|
91
|
+
returnValue: 'resultRows',
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
case 'run':
|
|
95
|
+
db.exec({ sql: payload.sql, bind: payload.bind });
|
|
96
|
+
return {};
|
|
97
|
+
case 'batch':
|
|
98
|
+
for (const stmt of payload as { sql: string; bind?: unknown[] }[])
|
|
99
|
+
db.exec({ sql: stmt.sql, bind: stmt.bind });
|
|
100
|
+
return {};
|
|
101
|
+
default:
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return engine;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
110
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(() => db?.close());
|
|
114
|
+
|
|
115
|
+
it('removes the cleared key on the batched fast path, keeps _00_* and required fields', async () => {
|
|
116
|
+
const engine = realEngine();
|
|
117
|
+
await engine.connect('anon');
|
|
118
|
+
const exec: Exec = (q, v) => engine.execute(q, v);
|
|
119
|
+
|
|
120
|
+
await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
|
|
121
|
+
await engine.upsert('broadcast', x, { _00_local: 'keep' }, 'merge');
|
|
122
|
+
const before = (await engine.getById('broadcast', x)) as Row;
|
|
123
|
+
expect(before.club).toBeDefined();
|
|
124
|
+
|
|
125
|
+
// The server unset `club`; `secret` is hidden from this reader (required,
|
|
126
|
+
// so its absence is not a clear).
|
|
127
|
+
await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
|
|
128
|
+
const after = (await engine.getById('broadcast', x)) as Row;
|
|
129
|
+
expect('club' in after).toBe(false);
|
|
130
|
+
expect(after).toMatchObject({
|
|
131
|
+
id: 'broadcast:x',
|
|
132
|
+
title: 't2',
|
|
133
|
+
note: 'n',
|
|
134
|
+
secret: 's',
|
|
135
|
+
_00_local: 'keep',
|
|
136
|
+
_00_rv: 2,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A fresh row stores no placeholder for the cleared key.
|
|
140
|
+
await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
|
|
141
|
+
const inserted = (await engine.getById('broadcast', fresh)) as Row;
|
|
142
|
+
expect(Object.keys(inserted).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('removes the cleared key on the single-statement merge path too', async () => {
|
|
146
|
+
const engine = realEngine();
|
|
147
|
+
await engine.connect('anon');
|
|
148
|
+
|
|
149
|
+
const y = new RecordId('broadcast', 'y');
|
|
150
|
+
await engine.query('UPSERT ONLY $id MERGE $c', {
|
|
151
|
+
id: y,
|
|
152
|
+
c: { club, note: 'n2', _00_local: 'keep' },
|
|
153
|
+
});
|
|
154
|
+
expect((await engine.getById('broadcast', y))?.club).toBeDefined();
|
|
155
|
+
await engine.query('UPSERT ONLY $id MERGE $c', { id: y, c: { club: undefined, note: 'n3' } });
|
|
156
|
+
const row = (await engine.getById('broadcast', y)) as Row;
|
|
157
|
+
expect('club' in row).toBe(false);
|
|
158
|
+
expect(row.note).toBe('n3');
|
|
159
|
+
expect(row._00_local).toBe('keep');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ==================== SurrealDB-WASM ====================
|
|
164
|
+
|
|
165
|
+
describe('landed body clears an absent optional field: SurrealDB-WASM engine', () => {
|
|
166
|
+
let db: Surreal;
|
|
167
|
+
|
|
168
|
+
beforeAll(async () => {
|
|
169
|
+
// The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
|
|
170
|
+
// and Node's fetch has no `file:` scheme: serve that one read from disk.
|
|
171
|
+
const realFetch = globalThis.fetch;
|
|
172
|
+
vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
|
|
173
|
+
const url = input instanceof URL ? input : new URL(String(input));
|
|
174
|
+
if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
|
|
175
|
+
return realFetch(input as RequestInfo, init);
|
|
176
|
+
});
|
|
177
|
+
const { createWasmEngines } = await import('@surrealdb/wasm');
|
|
178
|
+
db = new Surreal({ engines: createWasmEngines() });
|
|
179
|
+
await db.connect('mem://');
|
|
180
|
+
await db.use({ namespace: 'n', database: 'd' });
|
|
181
|
+
await db.query(`
|
|
182
|
+
DEFINE TABLE club SCHEMAFULL;
|
|
183
|
+
DEFINE TABLE broadcast SCHEMAFULL;
|
|
184
|
+
DEFINE FIELD title ON broadcast TYPE string;
|
|
185
|
+
DEFINE FIELD secret ON broadcast TYPE string;
|
|
186
|
+
DEFINE FIELD club ON broadcast TYPE option<record<club>>;
|
|
187
|
+
DEFINE FIELD note ON broadcast TYPE option<string>;
|
|
188
|
+
DEFINE FIELD _00_rv ON broadcast TYPE option<int>;
|
|
189
|
+
DEFINE FIELD _00_local ON broadcast TYPE option<string>;
|
|
190
|
+
`);
|
|
191
|
+
}, 60_000);
|
|
192
|
+
|
|
193
|
+
afterAll(async () => {
|
|
194
|
+
await db?.close();
|
|
195
|
+
vi.unstubAllGlobals();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const exec: Exec = (q, v) => db.query(q.sql, v) as unknown as Promise<unknown>;
|
|
199
|
+
const read = async (id: RecordId<string>): Promise<Record<string, unknown>> => {
|
|
200
|
+
const [row] = (await db.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown>];
|
|
201
|
+
return row;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
it('the undefined clear reaches SurrealDB as NONE and the MERGE removes the field', async () => {
|
|
205
|
+
await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
|
|
206
|
+
await db.query("UPDATE $id SET _00_local = 'keep'", { id: x });
|
|
207
|
+
expect(String((await read(x)).club)).toBe('club:c1');
|
|
208
|
+
|
|
209
|
+
// Would fail the whole transaction if the clear were NULL (a coercion error
|
|
210
|
+
// on `option<record<club>>`) or named the required `secret` as NONE.
|
|
211
|
+
await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
|
|
212
|
+
const after = await read(x);
|
|
213
|
+
expect('club' in after).toBe(false);
|
|
214
|
+
expect(after).toMatchObject({
|
|
215
|
+
title: 't2',
|
|
216
|
+
note: 'n',
|
|
217
|
+
secret: 's',
|
|
218
|
+
_00_local: 'keep',
|
|
219
|
+
_00_rv: 2,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
|
|
223
|
+
expect(Object.keys(await read(fresh)).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('why the clear is NONE on optional columns only: NULL and a required NONE both fail', async () => {
|
|
227
|
+
const z = new RecordId('broadcast', 'z');
|
|
228
|
+
await db.query('CREATE $id CONTENT $c', { id: z, c: { title: 't', secret: 's', club } });
|
|
229
|
+
await expect(
|
|
230
|
+
db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { club: null } })
|
|
231
|
+
).rejects.toThrow(/club/);
|
|
232
|
+
await expect(
|
|
233
|
+
db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { secret: undefined } })
|
|
234
|
+
).rejects.toThrow(/secret/);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
renderOrderSql,
|
|
7
7
|
renderWhereSql,
|
|
8
8
|
reviveRow,
|
|
9
|
+
serializeMergeRow,
|
|
9
10
|
serializeRow,
|
|
10
11
|
project,
|
|
11
12
|
projectedDataSql,
|
|
@@ -947,11 +948,12 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
947
948
|
if (mode === 'merge') {
|
|
948
949
|
// Merge in-SQL via json_patch (RFC7396 = MERGE semantics): on insert store
|
|
949
950
|
// the row, on conflict shallow-merge. Serialize once, reuse for VALUES and
|
|
950
|
-
// the patch. No read-modify-write round-trip. (RFC7396: null deletes key
|
|
951
|
-
|
|
951
|
+
// the patch. No read-modify-write round-trip. (RFC7396: null deletes key,
|
|
952
|
+
// which is how a key holding `undefined` clears the stored field.)
|
|
953
|
+
const { insert, patch } = serializeMergeRow({ ...data, id: key });
|
|
952
954
|
await this.call('run', {
|
|
953
955
|
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
954
|
-
bind: [key,
|
|
956
|
+
bind: [key, insert, patch],
|
|
955
957
|
});
|
|
956
958
|
return;
|
|
957
959
|
}
|
|
@@ -1191,10 +1193,12 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
1191
1193
|
// Serialize ONCE and reuse for both VALUES (fresh insert) and the
|
|
1192
1194
|
// json_patch (merge). Patching with `id` is a harmless no-op set, so
|
|
1193
1195
|
// the full row doubles as the delta — halves per-row stringify cost.
|
|
1194
|
-
|
|
1196
|
+
// A key holding `undefined` (a field cleared upstream) rides in the
|
|
1197
|
+
// delta as a `null` member, which json_patch removes.
|
|
1198
|
+
const { insert, patch } = serializeMergeRow({ ...op.data, id: key });
|
|
1195
1199
|
stmts.push({
|
|
1196
1200
|
sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
1197
|
-
bind: [key,
|
|
1201
|
+
bind: [key, insert, patch],
|
|
1198
1202
|
});
|
|
1199
1203
|
} else {
|
|
1200
1204
|
stmts.push({
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
renderOrderSql,
|
|
7
7
|
scalar,
|
|
8
8
|
serializeRow,
|
|
9
|
+
serializeMergeRow,
|
|
9
10
|
reviveRow,
|
|
10
11
|
project,
|
|
11
12
|
} from './sqlite-plan-sql';
|
|
@@ -102,3 +103,37 @@ describe('project', () => {
|
|
|
102
103
|
expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('serializeMergeRow', () => {
|
|
108
|
+
it('reuses one string for insert and patch when nothing is cleared', () => {
|
|
109
|
+
const row = { id: 't:1', a: 1, b: null };
|
|
110
|
+
const { insert, patch } = serializeMergeRow(row);
|
|
111
|
+
expect(insert).toBe(serializeRow(row));
|
|
112
|
+
expect(patch).toBe(insert);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// json_patch (RFC 7396) removes a member whose patch value is null; JSON drops
|
|
116
|
+
// an undefined one, which is how a cleared field used to survive the MERGE.
|
|
117
|
+
it('carries a top-level undefined as a null member in the patch only', () => {
|
|
118
|
+
const { insert, patch } = serializeMergeRow({ id: 't:1', a: 1, club: undefined, 'we"ird': undefined });
|
|
119
|
+
expect(JSON.parse(insert)).toStrictEqual({ id: 't:1', a: 1 });
|
|
120
|
+
expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', a: 1, club: null, 'we"ird': null });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('handles a row with nothing but cleared keys', () => {
|
|
124
|
+
const { insert, patch } = serializeMergeRow({ club: undefined });
|
|
125
|
+
expect(insert).toBe('{}');
|
|
126
|
+
expect(JSON.parse(patch)).toStrictEqual({ club: null });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('leaves nested undefined dropped (only top-level keys clear)', () => {
|
|
130
|
+
const { patch } = serializeMergeRow({ id: 't:1', meta: { x: undefined, y: 1 } });
|
|
131
|
+
expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', meta: { y: 1 } });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not append to a root the replacer collapsed to a scalar', () => {
|
|
135
|
+
const { insert, patch } = serializeMergeRow({ tb: 'user', id: 'u1', gone: undefined });
|
|
136
|
+
expect(insert).toBe('"user:u1"');
|
|
137
|
+
expect(patch).toBe(insert);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -70,6 +70,32 @@ export function serializeRow(row: Row): string {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
75
|
+
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
76
|
+
*
|
|
77
|
+
* A top-level own key holding `undefined` means "clear this field", which is
|
|
78
|
+
* what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
|
|
79
|
+
* such a key, so a patch built by {@link serializeRow} alone never mentioned
|
|
80
|
+
* it and the stored value survived every merge: a field cleared on the server
|
|
81
|
+
* stayed set locally. The patch therefore carries each one as a `null` member,
|
|
82
|
+
* which `json_patch` removes; the insert row simply lacks it, exactly as
|
|
83
|
+
* before. Appended to the already-serialized text, so the common case costs no
|
|
84
|
+
* second stringify. Nested `undefined` stays dropped (never a clear here).
|
|
85
|
+
*/
|
|
86
|
+
export function serializeMergeRow(row: Row): { insert: string; patch: string } {
|
|
87
|
+
const insert = serializeRow(row);
|
|
88
|
+
const cleared: string[] = [];
|
|
89
|
+
for (const key of Object.keys(row)) {
|
|
90
|
+
if (row[key] === undefined) cleared.push(`${JSON.stringify(key)}:null`);
|
|
91
|
+
}
|
|
92
|
+
// Not an object text (the replacer collapsed the root): nothing to append to.
|
|
93
|
+
if (cleared.length === 0 || insert[0] !== '{') return { insert, patch: insert };
|
|
94
|
+
const members = cleared.join(',');
|
|
95
|
+
const patch = insert === '{}' ? `{${members}}` : `${insert.slice(0, -1)},${members}}`;
|
|
96
|
+
return { insert, patch };
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
export function reviveRow(json: string): Row {
|
|
74
100
|
// Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
|
|
75
101
|
// `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
|
package/src/utils/parser.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { RecordId } from '@spooky-sync/query-builder';
|
|
3
|
-
import { parseParams, parseQueryParams } from './parser';
|
|
3
|
+
import { absentOptionalColumns, parseParams, parseQueryParams } from './parser';
|
|
4
4
|
|
|
5
5
|
const columns = {
|
|
6
6
|
white: { recordId: true },
|
|
@@ -52,3 +52,39 @@ describe('parseParams', () => {
|
|
|
52
52
|
expect(parsed).toEqual({ result: '1-0' });
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe('absentOptionalColumns', () => {
|
|
57
|
+
const schema = {
|
|
58
|
+
title: { type: 'string', optional: false },
|
|
59
|
+
club: { type: 'string', recordId: true, optional: true },
|
|
60
|
+
note: { type: 'string', optional: true },
|
|
61
|
+
_00_local: { type: 'string', optional: true },
|
|
62
|
+
id: { type: 'string', optional: true },
|
|
63
|
+
} as any;
|
|
64
|
+
|
|
65
|
+
// The bug: `UPDATE broadcast:x UNSET club` leaves no `club` key in the body,
|
|
66
|
+
// and a MERGE that does not name it keeps the stale local value.
|
|
67
|
+
it('names every optional column the row lacks, as undefined', () => {
|
|
68
|
+
const cleared = absentOptionalColumns(schema, { id: 'broadcast:x', title: 't', note: 'n' });
|
|
69
|
+
expect(cleared).toStrictEqual({ club: undefined });
|
|
70
|
+
expect(Object.prototype.hasOwnProperty.call(cleared, 'club')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('treats an explicit undefined (a decoded NONE) as absent', () => {
|
|
74
|
+
expect(absentOptionalColumns(schema, { title: 't', club: undefined, note: 'n' })).toStrictEqual(
|
|
75
|
+
{ club: undefined }
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('never names a required column, id, or _00_* bookkeeping', () => {
|
|
80
|
+
expect(absentOptionalColumns(schema, {})).toStrictEqual({ club: undefined, note: undefined });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps a present value, even a falsy one', () => {
|
|
84
|
+
expect(absentOptionalColumns(schema, { club: null, note: '' })).toStrictEqual({});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('is empty without a schema', () => {
|
|
88
|
+
expect(absentOptionalColumns(undefined, { a: 1 })).toStrictEqual({});
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/utils/parser.ts
CHANGED
|
@@ -16,6 +16,38 @@ export function cleanRecord(
|
|
|
16
16
|
return cleaned;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The OPTIONAL schema columns a full server row does not carry, each mapped to
|
|
21
|
+
* `undefined`, for spreading into a body that is landed with MERGE.
|
|
22
|
+
*
|
|
23
|
+
* The server omits an optional field that is unset (`UPDATE t UNSET club`
|
|
24
|
+
* leaves no `club` key in `SELECT *` or the SSP body), and MERGE keeps every
|
|
25
|
+
* key the patch does not mention, so without this the local store held on to
|
|
26
|
+
* a cleared value forever. `undefined` is the one representation both engines
|
|
27
|
+
* read as "remove": the SurrealDB SDK encodes it as NONE, which MERGE removes
|
|
28
|
+
* (NULL would be a type error on an `option<record<...>>` field), and the
|
|
29
|
+
* SQLite engine turns it into the `null` member that `json_patch` removes (see
|
|
30
|
+
* `serializeMergeRow`).
|
|
31
|
+
*
|
|
32
|
+
* Required columns are left alone. A SCHEMAFULL server cannot hold NONE in
|
|
33
|
+
* one, so its absence means the row was not readable in full (a field
|
|
34
|
+
* permission), not that it was cleared, and NONE in a typed local store is a
|
|
35
|
+
* coercion error that would fail the whole chunk's transaction. `id` and the
|
|
36
|
+
* local-only `_00_*` bookkeeping are never touched.
|
|
37
|
+
*/
|
|
38
|
+
export function absentOptionalColumns(
|
|
39
|
+
tableSchema: Record<string, ColumnSchema> | undefined,
|
|
40
|
+
record: Record<string, unknown>
|
|
41
|
+
): Record<string, undefined> {
|
|
42
|
+
const cleared: Record<string, undefined> = {};
|
|
43
|
+
if (!tableSchema) return cleared;
|
|
44
|
+
for (const [key, column] of Object.entries(tableSchema)) {
|
|
45
|
+
if (key === 'id' || key.startsWith('_00_') || column?.optional !== true) continue;
|
|
46
|
+
if (record[key] === undefined) cleared[key] = undefined;
|
|
47
|
+
}
|
|
48
|
+
return cleared;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
/**
|
|
20
52
|
* Parse a RECORD's fields against the table schema. Anything the schema does not
|
|
21
53
|
* know is dropped, which is what keeps a stray field out of a write.
|