@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 null;
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.109";
4959
- const WASM_VERSION = "0.0.1-canary.109";
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.109",
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.109",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.109",
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 { createDatabaseEventSystem, DatabaseEventTypes, type DatabaseEventSystem } from './events/index';
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
- EngineTx,
12
- Id,
13
- LocalStore,
14
- OrderBy,
15
- RelationFetch,
16
- Row,
17
- } from './cache-engine';
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({ err, Category: 'sp00ky-client::SqliteCacheEngine::worker' }, 'Worker 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 (transaction && ops.every((o) => o.kind === 'upsert' || o.kind === 'delete' || o.kind === 'deleteAll' || o.kind === 'noop')) {
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
- shaped = [null, ...ops.map(() => [])];
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>(query: SealedQuery<R>, vars?: Record<string, unknown>, opts?: { epoch?: number }): Promise<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, { select: op.select, orderBy: op.orderBy });
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
- return { ...op.data, id: stableKey(op.id) };
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 null;
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({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
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 = { roundTrips: 0, batchStatements: 0, maxBatch: 0, inFlight: 0, maxInFlight: 0, byType: {} };
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(c: WhereComparison, bind: unknown[], params: Record<string, unknown>): string {
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));