@spooky-sync/core 0.0.1-canary.109 → 0.0.1-canary.110
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
CHANGED
|
@@ -1538,6 +1538,30 @@ function setPath(obj, path, value) {
|
|
|
1538
1538
|
//#endregion
|
|
1539
1539
|
//#region src/services/database/sqlite-cache-engine.ts
|
|
1540
1540
|
/**
|
|
1541
|
+
* The statement result a pure-write op contributes to a query's results array.
|
|
1542
|
+
* Single source of truth shared by the per-op path (`execOp`) and the batched
|
|
1543
|
+
* fast path in `query()`, so the two can never diverge: a caller that reads a
|
|
1544
|
+
* statement's output sees the same shape whether or not the transaction took the
|
|
1545
|
+
* batch fast path. In particular `create()` compiles to an all-upsert tx
|
|
1546
|
+
* (`createSet` + `createMutation`) and reads `resultIndex:0` for the new row and
|
|
1547
|
+
* its id — the fast path previously returned empty arrays there, so the row (and
|
|
1548
|
+
* its id) was lost and the reconcile crashed in `encodeRecordId`.
|
|
1549
|
+
*
|
|
1550
|
+
* An upsert echoes the written row (`{...data, id}`) with no read-back — the
|
|
1551
|
+
* full merged row is only materialized for a LET-wrapped upsert (see the 'let'
|
|
1552
|
+
* case). delete/deleteAll yield `[]`; noop yields `null`.
|
|
1553
|
+
*/
|
|
1554
|
+
function pureWriteOpResult(op) {
|
|
1555
|
+
switch (op.kind) {
|
|
1556
|
+
case "upsert": return {
|
|
1557
|
+
...op.data,
|
|
1558
|
+
id: stableKey(op.id)
|
|
1559
|
+
};
|
|
1560
|
+
case "noop": return null;
|
|
1561
|
+
default: return [];
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1541
1565
|
* Local cache backend on official SQLite-WASM in a dedicated Worker (see
|
|
1542
1566
|
* `sqlite-worker.ts`), with OPFS SAHPool persistence. Storage model: one table
|
|
1543
1567
|
* per schema table, `id TEXT PRIMARY KEY, data TEXT` where `data` is the row as
|
|
@@ -1803,7 +1827,7 @@ var SqliteCacheEngine = class {
|
|
|
1803
1827
|
let shaped;
|
|
1804
1828
|
if (transaction && ops.every((o) => o.kind === "upsert" || o.kind === "delete" || o.kind === "deleteAll" || o.kind === "noop")) {
|
|
1805
1829
|
await this.runWriteBatch(ops);
|
|
1806
|
-
shaped = [null, ...ops.map(
|
|
1830
|
+
shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
1807
1831
|
} else {
|
|
1808
1832
|
const results = [];
|
|
1809
1833
|
const scope = {};
|
|
@@ -1860,10 +1884,7 @@ var SqliteCacheEngine = class {
|
|
|
1860
1884
|
}
|
|
1861
1885
|
case "upsert":
|
|
1862
1886
|
await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
|
|
1863
|
-
return
|
|
1864
|
-
...op.data,
|
|
1865
|
-
id: stableKey(op.id)
|
|
1866
|
-
};
|
|
1887
|
+
return pureWriteOpResult(op);
|
|
1867
1888
|
case "updateSet": {
|
|
1868
1889
|
const existing = await this.getById(tableOf(op.id), op.id) ?? { id: stableKey(op.id) };
|
|
1869
1890
|
for (const { path, op: setOp, value } of op.sets) if (setOp === "+=" || setOp === "-=") {
|
|
@@ -1876,11 +1897,11 @@ var SqliteCacheEngine = class {
|
|
|
1876
1897
|
}
|
|
1877
1898
|
case "delete":
|
|
1878
1899
|
await this.delete(tableOf(op.id), op.id);
|
|
1879
|
-
return
|
|
1900
|
+
return pureWriteOpResult(op);
|
|
1880
1901
|
case "deleteAll":
|
|
1881
1902
|
await this.ensureTable(op.table);
|
|
1882
1903
|
await this.call("run", { sql: `DELETE FROM "${op.table}"` });
|
|
1883
|
-
return
|
|
1904
|
+
return pureWriteOpResult(op);
|
|
1884
1905
|
case "let": {
|
|
1885
1906
|
let result = await this.execOp(op.inner, scope, vars);
|
|
1886
1907
|
if (op.inner.kind === "upsert") result = await this.getById(tableOf(op.inner.id), op.inner.id) ?? result;
|
|
@@ -1892,7 +1913,7 @@ var SqliteCacheEngine = class {
|
|
|
1892
1913
|
for (const { key, var: v } of op.entries) obj[key] = v in scope ? scope[v] : vars[v];
|
|
1893
1914
|
return obj;
|
|
1894
1915
|
}
|
|
1895
|
-
case "noop": return
|
|
1916
|
+
case "noop": return pureWriteOpResult(op);
|
|
1896
1917
|
}
|
|
1897
1918
|
}
|
|
1898
1919
|
/**
|
|
@@ -4955,8 +4976,8 @@ function parseBackendInfo(raw) {
|
|
|
4955
4976
|
|
|
4956
4977
|
//#endregion
|
|
4957
4978
|
//#region src/modules/devtools/index.ts
|
|
4958
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
4959
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
4979
|
+
const CORE_VERSION = "0.0.1-canary.110";
|
|
4980
|
+
const WASM_VERSION = "0.0.1-canary.110";
|
|
4960
4981
|
const SURREAL_VERSION = "3.0.3";
|
|
4961
4982
|
var DevToolsService = class {
|
|
4962
4983
|
eventsHistory = [];
|
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.110",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.110",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.110",
|
|
64
64
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
65
65
|
"@surrealdb/wasm": "^3.0.3",
|
|
66
66
|
"fast-json-patch": "^3.1.1",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { pureWriteOpResult } from './sqlite-cache-engine';
|
|
4
|
+
import { translateSurql } from './surql-translate';
|
|
5
|
+
import type { SqlOp } from './surql-translate';
|
|
6
|
+
import { surql } from '../../utils/surql';
|
|
7
|
+
|
|
8
|
+
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
9
|
+
// contributes to a query's per-statement results. The batched fast path in
|
|
10
|
+
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|
|
11
|
+
// reads a statement's output (e.g. `create()` reads `resultIndex:0` for the new
|
|
12
|
+
// row + its id) sees the same shape either way.
|
|
13
|
+
describe('pureWriteOpResult', () => {
|
|
14
|
+
it('echoes the written row (with id) for an upsert — no read-back', () => {
|
|
15
|
+
const op: SqlOp = {
|
|
16
|
+
kind: 'upsert',
|
|
17
|
+
id: 'connection:CONN_abc',
|
|
18
|
+
data: { provider: 'chesscom', username: 'hikaru' },
|
|
19
|
+
mode: 'replace',
|
|
20
|
+
};
|
|
21
|
+
expect(pureWriteOpResult(op)).toEqual({
|
|
22
|
+
provider: 'chesscom',
|
|
23
|
+
username: 'hikaru',
|
|
24
|
+
id: 'connection:CONN_abc',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('stringifies a RecordId id via stableKey', () => {
|
|
29
|
+
const op: SqlOp = {
|
|
30
|
+
kind: 'upsert',
|
|
31
|
+
id: new RecordId('connection', 'CONN_abc'),
|
|
32
|
+
data: { provider: 'lichess' },
|
|
33
|
+
mode: 'replace',
|
|
34
|
+
};
|
|
35
|
+
expect(pureWriteOpResult(op)).toEqual({ provider: 'lichess', id: 'connection:CONN_abc' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('yields [] for delete / deleteAll and null for noop', () => {
|
|
39
|
+
expect(pureWriteOpResult({ kind: 'delete', id: 'game:1' })).toEqual([]);
|
|
40
|
+
expect(pureWriteOpResult({ kind: 'deleteAll', table: 'game' })).toEqual([]);
|
|
41
|
+
expect(pureWriteOpResult({ kind: 'noop' })).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Regression: a single `create()` compiles to an all-upsert transaction
|
|
46
|
+
// (createSet for the row + createMutation for the pending-mutation log) and
|
|
47
|
+
// extracts `resultIndex:0` for the created row. The SQLite fast path must return
|
|
48
|
+
// that row (with its id) at that index — returning `[]` there dropped the id and
|
|
49
|
+
// crashed the reconcile in `encodeRecordId` ("reading 'table'").
|
|
50
|
+
describe('create() tx result shaping (fast path parity)', () => {
|
|
51
|
+
it('resultIndex:0 carries the created row with its id', () => {
|
|
52
|
+
const rid = new RecordId('connection', 'CONN_abc');
|
|
53
|
+
const mid = new RecordId('_00_pending_mutations', '1');
|
|
54
|
+
const vars = {
|
|
55
|
+
id: rid,
|
|
56
|
+
mid,
|
|
57
|
+
data_provider: 'chesscom',
|
|
58
|
+
data_username: 'hikaru',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Same statement pair DataModule.create emits.
|
|
62
|
+
const sealed = surql.seal(
|
|
63
|
+
surql.tx([
|
|
64
|
+
surql.createSet('id', [
|
|
65
|
+
{ key: 'provider', variable: 'data_provider' },
|
|
66
|
+
{ key: 'username', variable: 'data_username' },
|
|
67
|
+
]),
|
|
68
|
+
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
69
|
+
]),
|
|
70
|
+
{ resultIndex: 0 }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const { transaction, ops } = translateSurql(sealed.sql, vars);
|
|
74
|
+
expect(transaction).toBe(true);
|
|
75
|
+
// Both statements are upserts → the engine takes the all-write fast path.
|
|
76
|
+
expect(ops.every((o) => o.kind === 'upsert')).toBe(true);
|
|
77
|
+
|
|
78
|
+
// Fast-path shaping: [null (BEGIN), ...one result per statement].
|
|
79
|
+
const shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
80
|
+
const created = sealed.extract(shaped) as unknown as { id: unknown; provider: string };
|
|
81
|
+
|
|
82
|
+
expect(created.id).toBe('connection:CONN_abc');
|
|
83
|
+
expect(created.provider).toBe('chesscom');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -4,17 +4,39 @@ import type { Logger } from '../logger/index';
|
|
|
4
4
|
import type { Sp00kyConfig } from '../../types';
|
|
5
5
|
import type { SealedQuery } from '../../utils/surql';
|
|
6
6
|
import { resolveRelations, stableKey } from './relation-resolver';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createDatabaseEventSystem,
|
|
9
|
+
DatabaseEventTypes,
|
|
10
|
+
type DatabaseEventSystem,
|
|
11
|
+
} from './events/index';
|
|
8
12
|
import { StaleEpochError } from './local';
|
|
9
13
|
import { translateSurql, tableOf, setPath, getPath, type SqlOp } from './surql-translate';
|
|
10
|
-
import type {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The statement result a pure-write op contributes to a query's results array.
|
|
18
|
+
* Single source of truth shared by the per-op path (`execOp`) and the batched
|
|
19
|
+
* fast path in `query()`, so the two can never diverge: a caller that reads a
|
|
20
|
+
* statement's output sees the same shape whether or not the transaction took the
|
|
21
|
+
* batch fast path. In particular `create()` compiles to an all-upsert tx
|
|
22
|
+
* (`createSet` + `createMutation`) and reads `resultIndex:0` for the new row and
|
|
23
|
+
* its id — the fast path previously returned empty arrays there, so the row (and
|
|
24
|
+
* its id) was lost and the reconcile crashed in `encodeRecordId`.
|
|
25
|
+
*
|
|
26
|
+
* An upsert echoes the written row (`{...data, id}`) with no read-back — the
|
|
27
|
+
* full merged row is only materialized for a LET-wrapped upsert (see the 'let'
|
|
28
|
+
* case). delete/deleteAll yield `[]`; noop yields `null`.
|
|
29
|
+
*/
|
|
30
|
+
export function pureWriteOpResult(op: SqlOp): unknown {
|
|
31
|
+
switch (op.kind) {
|
|
32
|
+
case 'upsert':
|
|
33
|
+
return { ...op.data, id: stableKey(op.id) };
|
|
34
|
+
case 'noop':
|
|
35
|
+
return null;
|
|
36
|
+
default:
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
18
40
|
|
|
19
41
|
/**
|
|
20
42
|
* Local cache backend on official SQLite-WASM in a dedicated Worker (see
|
|
@@ -98,7 +120,10 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
98
120
|
// call hung forever — reject them all with a clear error.
|
|
99
121
|
const failAll = (msg: string) => {
|
|
100
122
|
const err = new Error(`SQLite worker crashed: ${msg}`);
|
|
101
|
-
this.logger.error(
|
|
123
|
+
this.logger.error(
|
|
124
|
+
{ err, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
|
|
125
|
+
'Worker error'
|
|
126
|
+
);
|
|
102
127
|
for (const [, p] of this.pending) p.reject(err);
|
|
103
128
|
this.pending.clear();
|
|
104
129
|
};
|
|
@@ -333,9 +358,25 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
333
358
|
// one SQLite transaction — instead of 1-2 worker round-trips PER row. This
|
|
334
359
|
// is the dominant sync-down cost; per-op execution here caused the churn
|
|
335
360
|
// OOM. Mixed txs (LET/RETURN single-record mutations) keep the per-op path.
|
|
336
|
-
if (
|
|
361
|
+
if (
|
|
362
|
+
transaction &&
|
|
363
|
+
ops.every(
|
|
364
|
+
(o) =>
|
|
365
|
+
o.kind === 'upsert' ||
|
|
366
|
+
o.kind === 'delete' ||
|
|
367
|
+
o.kind === 'deleteAll' ||
|
|
368
|
+
o.kind === 'noop'
|
|
369
|
+
)
|
|
370
|
+
) {
|
|
337
371
|
await this.runWriteBatch(ops);
|
|
338
|
-
|
|
372
|
+
// Shape each statement's result the SAME as the per-op path (`execOp`),
|
|
373
|
+
// so a caller reading a statement's output still works after taking the
|
|
374
|
+
// batch fast path. A single `create()` compiles to an all-upsert tx
|
|
375
|
+
// (createSet + createMutation) and reads `resultIndex:0` for the new
|
|
376
|
+
// row + its id; returning empty arrays here dropped that row (id became
|
|
377
|
+
// undefined → the reconcile crashed in `encodeRecordId`). The
|
|
378
|
+
// single-batch write is kept — this only rebuilds the return value.
|
|
379
|
+
shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
339
380
|
} else {
|
|
340
381
|
const results: unknown[] = [];
|
|
341
382
|
// Per-query scope holds `LET $var = (...)` bindings for later statements
|
|
@@ -365,7 +406,11 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
365
406
|
}
|
|
366
407
|
}
|
|
367
408
|
|
|
368
|
-
async execute<R>(
|
|
409
|
+
async execute<R>(
|
|
410
|
+
query: SealedQuery<R>,
|
|
411
|
+
vars?: Record<string, unknown>,
|
|
412
|
+
opts?: { epoch?: number }
|
|
413
|
+
): Promise<R> {
|
|
369
414
|
const raw = await this.query<unknown[]>(query.sql, vars, opts);
|
|
370
415
|
return query.extract(raw);
|
|
371
416
|
}
|
|
@@ -382,12 +427,15 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
382
427
|
switch (op.kind) {
|
|
383
428
|
case 'getById': {
|
|
384
429
|
const row = await this.getById(tableOf(op.id), op.id);
|
|
385
|
-
if (op.value) return row ? row[op.value] ?? null : null;
|
|
430
|
+
if (op.value) return row ? (row[op.value] ?? null) : null;
|
|
386
431
|
return row ? (op.select ? project(row, op.select) : row) : null;
|
|
387
432
|
}
|
|
388
433
|
case 'selectByIds': {
|
|
389
434
|
if (op.ids.length === 0) return [];
|
|
390
|
-
let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
|
|
435
|
+
let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
|
|
436
|
+
select: op.select,
|
|
437
|
+
orderBy: op.orderBy,
|
|
438
|
+
});
|
|
391
439
|
if (op.value) return rows.map((r) => r[op.value!]);
|
|
392
440
|
return rows;
|
|
393
441
|
}
|
|
@@ -401,8 +449,9 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
401
449
|
// Cheap return — no read-back. The full merged row is only needed by a
|
|
402
450
|
// LET-wrapped upsert, which reads it back in the 'let' case below. This
|
|
403
451
|
// avoids an extra worker round-trip + full-row parse on EVERY sync-down
|
|
404
|
-
// write (the hot path under rapid churn).
|
|
405
|
-
|
|
452
|
+
// write (the hot path under rapid churn). Shared with the batch fast
|
|
453
|
+
// path so the two never drift.
|
|
454
|
+
return pureWriteOpResult(op);
|
|
406
455
|
case 'updateSet': {
|
|
407
456
|
const existing = (await this.getById(tableOf(op.id), op.id)) ?? { id: stableKey(op.id) };
|
|
408
457
|
for (const { path, op: setOp, value } of op.sets) {
|
|
@@ -419,11 +468,11 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
419
468
|
}
|
|
420
469
|
case 'delete':
|
|
421
470
|
await this.delete(tableOf(op.id), op.id);
|
|
422
|
-
return
|
|
471
|
+
return pureWriteOpResult(op);
|
|
423
472
|
case 'deleteAll':
|
|
424
473
|
await this.ensureTable(op.table);
|
|
425
474
|
await this.call('run', { sql: `DELETE FROM "${op.table}"` });
|
|
426
|
-
return
|
|
475
|
+
return pureWriteOpResult(op);
|
|
427
476
|
case 'let': {
|
|
428
477
|
let result = await this.execOp(op.inner, scope, vars);
|
|
429
478
|
// A LET-bound UPSERT must expose the FULL merged row (e.g.
|
|
@@ -443,7 +492,7 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
443
492
|
return obj;
|
|
444
493
|
}
|
|
445
494
|
case 'noop':
|
|
446
|
-
return
|
|
495
|
+
return pureWriteOpResult(op);
|
|
447
496
|
}
|
|
448
497
|
}
|
|
449
498
|
|
|
@@ -459,7 +508,9 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
459
508
|
if (!tables.has(t)) {
|
|
460
509
|
tables.add(t);
|
|
461
510
|
this.knownTables.add(t);
|
|
462
|
-
stmts.push({
|
|
511
|
+
stmts.push({
|
|
512
|
+
sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
|
|
513
|
+
});
|
|
463
514
|
}
|
|
464
515
|
};
|
|
465
516
|
for (const op of ops) {
|
|
@@ -538,7 +589,14 @@ interface SqliteStats {
|
|
|
538
589
|
function getStats(): SqliteStats {
|
|
539
590
|
const g = globalThis as unknown as { __sqliteStats?: SqliteStats };
|
|
540
591
|
if (!g.__sqliteStats) {
|
|
541
|
-
g.__sqliteStats = {
|
|
592
|
+
g.__sqliteStats = {
|
|
593
|
+
roundTrips: 0,
|
|
594
|
+
batchStatements: 0,
|
|
595
|
+
maxBatch: 0,
|
|
596
|
+
inFlight: 0,
|
|
597
|
+
maxInFlight: 0,
|
|
598
|
+
byType: {},
|
|
599
|
+
};
|
|
542
600
|
}
|
|
543
601
|
return g.__sqliteStats;
|
|
544
602
|
}
|
|
@@ -551,7 +609,11 @@ function renderOrderSql(orderBy: OrderBy): string {
|
|
|
551
609
|
.join(', ')}`;
|
|
552
610
|
}
|
|
553
611
|
|
|
554
|
-
function comparisonSql(
|
|
612
|
+
function comparisonSql(
|
|
613
|
+
c: WhereComparison,
|
|
614
|
+
bind: unknown[],
|
|
615
|
+
params: Record<string, unknown>
|
|
616
|
+
): string {
|
|
555
617
|
const lhs = c.field === 'id' ? 'id' : `json_extract(data, '$.${c.field}')`;
|
|
556
618
|
const value = c.paramRef ? params[c.paramRef] : c.value;
|
|
557
619
|
bind.push(scalar(value));
|