@spooky-sync/core 0.0.1-canary.208 → 0.0.1-canary.209

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.
@@ -1,6 +1,26 @@
1
1
  import { a as renderOrderSql, i as projectedDataSql, l as resolveRelations, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as stableKey } from "./sqlite-open.js";
2
2
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
3
3
 
4
+ //#region src/services/database/sqlite-lock-verify.ts
5
+ const LOCK_VERIFY_TIMEOUT_MS = 2e3;
6
+ async function verifyLockStillHeld$1(locks, name, fence, timeoutMs = LOCK_VERIFY_TIMEOUT_MS, warn = () => {}) {
7
+ if (!locks || !name || typeof locks.query !== "function") return;
8
+ let timer;
9
+ try {
10
+ const state = await Promise.race([locks.query(), new Promise((resolve) => {
11
+ timer = setTimeout(() => resolve(null), timeoutMs);
12
+ })]);
13
+ if (state === null) {
14
+ warn(`lock verification did not answer within ${timeoutMs}ms; trusting the steal callback`);
15
+ return;
16
+ }
17
+ if (!(state.held ?? []).some((l) => l.name === name)) await fence("lock missing after suspected freeze");
18
+ } catch {} finally {
19
+ if (timer) clearTimeout(timer);
20
+ }
21
+ }
22
+
23
+ //#endregion
4
24
  //#region src/services/database/sqlite-select.ts
5
25
  function ensureTable(db, table) {
6
26
  if (db.knownTables.has(table)) return;
@@ -230,13 +250,8 @@ function noteTick() {
230
250
  });
231
251
  }
232
252
  setInterval(noteTick, 1e3);
233
- async function verifyLockStillHeld() {
234
- const locks = workerLocks();
235
- const name = currentLock?.name;
236
- if (!locks || !name || typeof locks.query !== "function") return;
237
- try {
238
- if (!((await locks.query()).held ?? []).some((l) => l.name === name)) await fence("lock missing after suspected freeze");
239
- } catch {}
253
+ function verifyLockStillHeld() {
254
+ return verifyLockStillHeld$1(workerLocks(), currentLock?.name, (reason) => fence(reason), void 0, (msg) => console.warn(`[sqlite-worker] ${msg}`));
240
255
  }
241
256
  async function open(dbName, useOpfs, systemTables = [], workerLockName, openOptions) {
242
257
  if (workerLockName) {
package/dist/types.d.ts CHANGED
@@ -504,6 +504,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
504
504
  * failure the queue retries. `0` disables. Defaults to `60_000`.
505
505
  */
506
506
  queryTimeoutMs?: number;
507
+ /**
508
+ * Deadline (ms) for every LOCAL store operation (a SQLite worker round trip,
509
+ * or a query on the in-process surrealdb engine). Local ops are serialized
510
+ * too, and one that never answered - a worker starved behind a long select,
511
+ * a lock verification with no clock - left every `db.create`/`db.update`
512
+ * promise pending for the tab's lifetime. On expiry the call rejects with
513
+ * `LocalOpTimeoutError` (the op itself keeps running in the engine and is
514
+ * not retried). `0` disables. Defaults to `30_000`.
515
+ */
516
+ localOpTimeoutMs?: number;
507
517
  };
508
518
  /** The schema definition. */
509
519
  schema: S;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.208",
3
+ "version": "0.0.1-canary.209",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.208",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.208",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.209",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.209",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "blurhash": "^2.0.5",
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  BlobUrlLease,
24
24
  } from './services/blobs/index';
25
25
  export { semverGt } from './utils/semver';
26
+ export { LocalOpTimeoutError, DEFAULT_LOCAL_OP_TIMEOUT_MS } from './services/database/errors';
26
27
  export { fileToUint8Array, textToHtml } from './utils/index';
27
28
  export {
28
29
  blurhashSidecarPath,
@@ -123,3 +123,77 @@ describe('DataModule pending-id cache', () => {
123
123
  expect([...deletes]).toEqual(['game:b']);
124
124
  });
125
125
  });
126
+
127
+ describe('DataModule pending-id cache: generations', () => {
128
+ beforeEach(() => vi.useFakeTimers());
129
+ afterEach(() => vi.useRealTimers());
130
+
131
+ // The outbox read is a round trip down the local op queue, so it can be
132
+ // queued BEFORE a create's outbox row commits and resolve AFTER
133
+ // `invalidatePendingIds()` ran for it. Cached as-is, that pre-create set
134
+ // hid the new row from every materialization that joined the read - the one
135
+ // stream update a CREATE gets, so the row was invisible until reload.
136
+ function makeDeferredModule() {
137
+ const pendingReads: Array<(rows: any[]) => void> = [];
138
+ const state = { reads: 0 };
139
+ const local = {
140
+ query: vi.fn(
141
+ () =>
142
+ new Promise<any[]>((resolve) => {
143
+ state.reads++;
144
+ pendingReads.push((rows) => resolve([rows]));
145
+ })
146
+ ),
147
+ };
148
+ const dm = new DataModule({} as any, local as any, { tables: [] } as any, makeLogger(), 100);
149
+ return { dm, state, pendingReads };
150
+ }
151
+ // Let the awaiting loop observe a resolved read and issue the next one.
152
+ const settle = async () => {
153
+ for (let i = 0; i < 8; i++) await Promise.resolve();
154
+ };
155
+
156
+ it('does not cache a read that was in flight when the outbox changed', async () => {
157
+ const { dm, state, pendingReads } = makeDeferredModule();
158
+ const first = dm.getPendingRecordIds();
159
+ expect(state.reads).toBe(1);
160
+ // The create commits and invalidates while the read is still out.
161
+ (dm as any).invalidatePendingIds();
162
+ // The stale read answers with the pre-create outbox...
163
+ pendingReads[0]!([]);
164
+ await settle();
165
+ // ...so the joiner re-read under the new generation and gets the fresh one.
166
+ expect(state.reads).toBe(2);
167
+ pendingReads[1]!([pending('new', 'create')]);
168
+ expect([...(await first).writes]).toEqual(['game:new']);
169
+ });
170
+
171
+ it('a stale in-flight result never repopulates the cache', async () => {
172
+ const { dm, state, pendingReads } = makeDeferredModule();
173
+ const first = dm.getPendingRecordIds();
174
+ (dm as any).invalidatePendingIds();
175
+ pendingReads[0]!([]);
176
+ await settle();
177
+ pendingReads[1]!([pending('new', 'create')]);
178
+ await first;
179
+ // A fresh call must be served from the cache the CURRENT generation's
180
+ // read wrote, i.e. see the new row without another read.
181
+ const again = await dm.getPendingRecordIds();
182
+ expect(state.reads).toBe(2);
183
+ expect([...again.writes]).toEqual(['game:new']);
184
+ });
185
+
186
+ it('bounds the re-read when the outbox changes on every tick', async () => {
187
+ const { dm, state, pendingReads } = makeDeferredModule();
188
+ const call = dm.getPendingRecordIds();
189
+ for (let i = 0; i < 5; i++) {
190
+ (dm as any).invalidatePendingIds();
191
+ pendingReads[i]?.([pending(`r${i}`)]);
192
+ await settle();
193
+ }
194
+ const result = await call;
195
+ // Three reads at most, and the caller still gets an answer.
196
+ expect(state.reads).toBeLessThanOrEqual(3);
197
+ expect(result.writes.size).toBe(1);
198
+ });
199
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryPlan } from '@spooky-sync/query-builder';
5
+ import type { QueryState, RecordVersionArray } from '../../types';
6
+
7
+ /**
8
+ * A row this client wrote itself reaches server membership at the very
9
+ * version the local CREATE memoized, so the sync engine rightly fetches
10
+ * nothing when the membership lands - and then nothing re-materialized the
11
+ * query: `remoteArray` gained the id and no subscriber heard about it until a
12
+ * reload rebuilt the view from membership. `scheduleRematerialize` closes that
13
+ * gap through the same per-query debounce a real stream update uses.
14
+ */
15
+
16
+ const noop = () => {};
17
+ function makeLogger(): any {
18
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
19
+ logger.child = () => logger;
20
+ return logger;
21
+ }
22
+
23
+ const schema = { tables: [{ name: 'message', columns: {} }] } as any;
24
+ const plan: QueryPlan = { table: 'message', where: [['conversation', '=', 'conversation:c1']] } as any;
25
+
26
+ function makeQueryState(hash: string, remoteArray: RecordVersionArray, localArray: RecordVersionArray): QueryState {
27
+ return {
28
+ config: {
29
+ id: new RecordId('_00_query', hash),
30
+ surql: 'SELECT * FROM message WHERE conversation = $conversation;',
31
+ plan,
32
+ params: { conversation: 'conversation:c1' },
33
+ localArray,
34
+ remoteArray,
35
+ membershipKnown: true,
36
+ ttl: '10m',
37
+ lastActiveAt: new Date(),
38
+ tableName: 'message',
39
+ },
40
+ records: [],
41
+ ttlTimer: null,
42
+ ttlDurationMs: 0,
43
+ updateCount: 0,
44
+ lastUpdatedAt: null,
45
+ materializationSamples: [],
46
+ lastIngestLatencyMs: null,
47
+ errorCount: 0,
48
+ status: 'idle',
49
+ phaseSamples: {},
50
+ phaseLast: {},
51
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
52
+ } as QueryState;
53
+ }
54
+
55
+ function setup(remoteArray: RecordVersionArray, localArray: RecordVersionArray) {
56
+ const body = { id: new RecordId('message', 'm1'), text: 'hi' };
57
+ const local: any = {
58
+ epoch: 1,
59
+ select: vi.fn(async (p: QueryPlan) => {
60
+ const ids = ((p as any).ids as RecordId[] | undefined) ?? [];
61
+ return ids.some((id) => id.toString() === 'message:m1') ? [body] : [];
62
+ }),
63
+ query: vi.fn(async (sql: string) => {
64
+ if (sql.includes('_00_pending_mutations')) return [[]];
65
+ return [[]];
66
+ }),
67
+ };
68
+ const dm = new DataModule({} as any, local, schema, makeLogger(), 10);
69
+ const hash = 'q1';
70
+ (dm as any).activeQueries.set(hash, makeQueryState(hash, remoteArray, localArray));
71
+ const subscriber = vi.fn();
72
+ (dm as any).subscriptions.set(hash, new Set([subscriber]));
73
+ return { dm, local, hash, subscriber };
74
+ }
75
+
76
+ describe('DataModule.scheduleRematerialize', () => {
77
+ afterEach(() => vi.useRealTimers());
78
+
79
+ it('notifies subscribers with the row once membership holds it', async () => {
80
+ vi.useFakeTimers();
81
+ const { dm, local, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
82
+ dm.scheduleRematerialize(hash);
83
+ await vi.advanceTimersByTimeAsync(20);
84
+ expect(subscriber).toHaveBeenCalledTimes(1);
85
+ expect(subscriber.mock.calls[0]![0].map((r: any) => r.id.toString())).toEqual(['message:m1']);
86
+ // A synthetic update describes no ingest: nothing is persisted to _00_query.
87
+ expect(local.query.mock.calls.some(([sql]: [string]) => sql.includes('localArray'))).toBe(false);
88
+ });
89
+
90
+ it('is a no-op behind a pending real stream update', async () => {
91
+ vi.useFakeTimers();
92
+ const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
93
+ await dm.onStreamUpdate({ queryHash: hash, localArray: [['message:m1', 1]], op: 'CREATE' });
94
+ dm.scheduleRematerialize(hash);
95
+ await vi.advanceTimersByTimeAsync(20);
96
+ // Exactly one notify: the real update's, not two.
97
+ expect(subscriber).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('does not notify when the records are unchanged', async () => {
101
+ vi.useFakeTimers();
102
+ const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
103
+ dm.scheduleRematerialize(hash);
104
+ await vi.advanceTimersByTimeAsync(20);
105
+ dm.scheduleRematerialize(hash);
106
+ await vi.advanceTimersByTimeAsync(20);
107
+ expect(subscriber).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('ignores an unknown query', () => {
111
+ const { dm } = setup([], []);
112
+ expect(() => dm.scheduleRematerialize('nope')).not.toThrow();
113
+ });
114
+ });
@@ -428,6 +428,12 @@ export class DataModule<S extends SchemaStructure> {
428
428
  return;
429
429
  }
430
430
 
431
+ this.queueStreamUpdate(update);
432
+ }
433
+
434
+ /** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
435
+ private queueStreamUpdate(update: StreamUpdate): void {
436
+ const { queryHash } = update;
431
437
  // Clear existing timer if any
432
438
  if (this.debounceTimers.has(queryHash)) {
433
439
  // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
@@ -445,6 +451,31 @@ export class DataModule<S extends SchemaStructure> {
445
451
  this.debounceTimers.set(queryHash, timer);
446
452
  }
447
453
 
454
+ /**
455
+ * Re-materialize + notify a query whose MEMBERSHIP changed without any row
456
+ * needing to be fetched, i.e. without the SSP stream update that normally
457
+ * carries the notify. That is every row this client wrote itself: the local
458
+ * CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
459
+ * sync engine rightly fetches nothing - and then nobody told the subscribers
460
+ * that `remoteArray` now holds the id. The row appeared on reload only.
461
+ *
462
+ * Routed through the same per-query debounce as a real stream update, so it
463
+ * cannot race one: a pending real update already materializes against the
464
+ * current `remoteArray` and wins. The synthetic update re-uses the circuit's
465
+ * last `localArray` and skips the persist/metrics that describe an ingest.
466
+ */
467
+ scheduleRematerialize(queryHash: string): void {
468
+ if (this.debounceTimers.has(queryHash)) return;
469
+ const queryState = this.activeQueries.get(queryHash);
470
+ if (!queryState) return;
471
+ this.queueStreamUpdate({
472
+ queryHash,
473
+ localArray: queryState.config.localArray ?? [],
474
+ op: 'UPDATE',
475
+ synthetic: true,
476
+ });
477
+ }
478
+
448
479
  /**
449
480
  * Process a query's pending (debounced) stream update NOW instead of on the
450
481
  * trailing edge. Called by the sync engine before it flips a query back to
@@ -614,11 +645,24 @@ export class DataModule<S extends SchemaStructure> {
614
645
  private pendingIdsAt = 0;
615
646
  private pendingIdsInflight: Promise<{ writes: Set<string>; deletes: Set<string> }> | null = null;
616
647
  private static readonly PENDING_IDS_TTL_MS = 250;
648
+ // Bumped by every invalidation. A read carries the generation it started
649
+ // under, and a result from an older generation is neither cached nor
650
+ // returned to a caller that asked after the invalidation: it was taken
651
+ // before the outbox row it needs existed. Without this, a materialization
652
+ // that joined a read already in flight when `create()` invalidated saw the
653
+ // pre-create set, found "no change", and skipped its notify - the one
654
+ // stream update a CREATE gets, so the row stayed invisible until reload.
655
+ private pendingIdsGen = 0;
656
+ private static readonly PENDING_IDS_MAX_REREADS = 3;
617
657
 
618
658
  /** Drop the cached outbox ids. Cheap; call it on anything that could change
619
659
  * `_00_pending_mutations`. */
620
660
  private invalidatePendingIds(): void {
621
661
  this.pendingIds = null;
662
+ this.pendingIdsGen++;
663
+ // The in-flight read (if any) predates this invalidation; whoever joins
664
+ // next starts a fresh one. Its own joiners re-read via the generation check.
665
+ this.pendingIdsInflight = null;
622
666
  }
623
667
 
624
668
  /**
@@ -770,7 +814,7 @@ export class DataModule<S extends SchemaStructure> {
770
814
  // normal re-query path.
771
815
  const newRecords = await this.materializeRecords(queryState, localArray);
772
816
  if (epoch !== this.local.epoch) return;
773
- queryState.config.localArray = localArray;
817
+ if (!update.synthetic) queryState.config.localArray = localArray;
774
818
 
775
819
  const prevJson = JSON.stringify(queryState.records);
776
820
  const newJson = JSON.stringify(newRecords);
@@ -785,30 +829,34 @@ export class DataModule<S extends SchemaStructure> {
785
829
  queryState.lastUpdatedAt = Date.now();
786
830
  }
787
831
 
788
- await this.local.query(
789
- surql.seal(
790
- surql.updateSet('id', [
791
- 'localArray',
792
- 'rowCount',
793
- 'updateCount',
794
- 'lastIngestLatency',
795
- 'materializationP55',
796
- 'materializationP90',
797
- 'materializationP99',
798
- ])
799
- ),
800
- {
801
- id: queryState.config.id,
802
- localArray,
803
- rowCount: localArray.length,
804
- updateCount: queryState.updateCount,
805
- lastIngestLatency: queryState.lastIngestLatencyMs,
806
- materializationP55: percentiles.p55,
807
- materializationP90: percentiles.p90,
808
- materializationP99: percentiles.p99,
809
- },
810
- { epoch }
811
- );
832
+ // A synthetic re-materialize did not come from an ingest: nothing about
833
+ // the circuit's view changed, so there is nothing to persist for it.
834
+ if (!update.synthetic) {
835
+ await this.local.query(
836
+ surql.seal(
837
+ surql.updateSet('id', [
838
+ 'localArray',
839
+ 'rowCount',
840
+ 'updateCount',
841
+ 'lastIngestLatency',
842
+ 'materializationP55',
843
+ 'materializationP90',
844
+ 'materializationP99',
845
+ ])
846
+ ),
847
+ {
848
+ id: queryState.config.id,
849
+ localArray,
850
+ rowCount: localArray.length,
851
+ updateCount: queryState.updateCount,
852
+ lastIngestLatency: queryState.lastIngestLatencyMs,
853
+ materializationP55: percentiles.p55,
854
+ materializationP90: percentiles.p90,
855
+ materializationP99: percentiles.p99,
856
+ },
857
+ { epoch }
858
+ );
859
+ }
812
860
 
813
861
  if (!recordsChanged) {
814
862
  this.logger.debug(
@@ -1249,18 +1297,32 @@ export class DataModule<S extends SchemaStructure> {
1249
1297
  }
1250
1298
  // Single-flight: one ingest fans out to many queries materializing at once,
1251
1299
  // and without this they all queue their own identical read behind each other.
1252
- if (!this.pendingIdsInflight) {
1253
- this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
1254
- this.pendingIdsInflight = null;
1255
- });
1300
+ // Bounded re-read: a result from a generation older than the one current
1301
+ // by the time it lands was taken before some outbox row existed, so it is
1302
+ // re-issued rather than returned. Bounded, because a write on every tick
1303
+ // must not spin this forever - the last read is returned then.
1304
+ let fresh: { writes: Set<string>; deletes: Set<string> } | null = null;
1305
+ for (let attempt = 0; attempt < DataModule.PENDING_IDS_MAX_REREADS; attempt++) {
1306
+ const gen = this.pendingIdsGen;
1307
+ if (!this.pendingIdsInflight) {
1308
+ const read = this.readPendingRecordIds(gen).finally(() => {
1309
+ // Only clear the slot this read still owns; an invalidation in the
1310
+ // meantime has replaced it (with null) for a newer read to fill.
1311
+ if (this.pendingIdsInflight === read) this.pendingIdsInflight = null;
1312
+ });
1313
+ this.pendingIdsInflight = read;
1314
+ }
1315
+ fresh = await this.pendingIdsInflight;
1316
+ if (gen === this.pendingIdsGen) break;
1256
1317
  }
1257
- const fresh = await this.pendingIdsInflight;
1258
- return { writes: new Set(fresh.writes), deletes: new Set(fresh.deletes) };
1318
+ // oxlint-disable-next-line no-non-null-assertion -- the loop runs at least once
1319
+ return { writes: new Set(fresh!.writes), deletes: new Set(fresh!.deletes) };
1259
1320
  }
1260
1321
 
1261
1322
  /** The uncached read. Also the reload path after an invalidation, so the ids
1262
- * still survive a reload exactly as before. */
1263
- private async readPendingRecordIds(): Promise<{
1323
+ * still survive a reload exactly as before. `gen` is the generation the read
1324
+ * was issued under; the result is cached only if it is still current. */
1325
+ private async readPendingRecordIds(gen: number): Promise<{
1264
1326
  writes: Set<string>;
1265
1327
  deletes: Set<string>;
1266
1328
  }> {
@@ -1285,8 +1347,10 @@ export class DataModule<S extends SchemaStructure> {
1285
1347
  // not a statement that the outbox is empty.
1286
1348
  return { writes, deletes };
1287
1349
  }
1288
- this.pendingIds = { writes, deletes };
1289
- this.pendingIdsAt = Date.now();
1350
+ if (gen === this.pendingIdsGen) {
1351
+ this.pendingIds = { writes, deletes };
1352
+ this.pendingIdsAt = Date.now();
1353
+ }
1290
1354
  return { writes, deletes };
1291
1355
  }
1292
1356
 
@@ -1757,18 +1821,36 @@ export class DataModule<S extends SchemaStructure> {
1757
1821
  })
1758
1822
  );
1759
1823
 
1824
+ // The local tx has committed: the row and its outbox entry exist. The
1825
+ // cached pending-id sets no longer describe the outbox, so drop them HERE,
1826
+ // before the ingest below fans out to every view's materialization (which
1827
+ // is exactly when they are read).
1828
+ this.invalidatePendingIds();
1829
+
1760
1830
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
1761
1831
 
1762
- // Save to cache (which handles DBSP ingestion)
1763
- await this.cache.save(
1764
- {
1765
- table: tableName,
1766
- op: 'CREATE',
1767
- record: parsedRecord,
1768
- version: 1,
1769
- },
1770
- true
1771
- );
1832
+ // Save to cache (which handles DBSP ingestion). Best-effort, like
1833
+ // `delete()`: the local write is durable and the outbox row exists, so a
1834
+ // throw from the in-browser ingest must not abort the rest of this method -
1835
+ // it used to, and then the mutation was never handed to sync, the row sat
1836
+ // in the outbox until the next leader promotion re-scanned it, and the
1837
+ // caller's promise rejected after the write had already happened.
1838
+ try {
1839
+ await this.cache.save(
1840
+ {
1841
+ table: tableName,
1842
+ op: 'CREATE',
1843
+ record: parsedRecord,
1844
+ version: 1,
1845
+ },
1846
+ true
1847
+ );
1848
+ } catch (err) {
1849
+ this.logger.error(
1850
+ { err, id, Category: 'sp00ky-client::DataModule::create' },
1851
+ 'SSP create-ingest failed; the row is written and queued, but views only see it once membership arrives'
1852
+ );
1853
+ }
1772
1854
 
1773
1855
  // Emit mutation event for sync
1774
1856
  const mutationEvent: CreateEvent = {
@@ -1780,8 +1862,6 @@ export class DataModule<S extends SchemaStructure> {
1780
1862
  tableName,
1781
1863
  };
1782
1864
 
1783
- // A new outbox row: the cached pending-id sets no longer describe it.
1784
- this.invalidatePendingIds();
1785
1865
  for (const callback of this.mutationCallbacks) {
1786
1866
  callback([mutationEvent]);
1787
1867
  }
@@ -1849,18 +1929,28 @@ export class DataModule<S extends SchemaStructure> {
1849
1929
  }
1850
1930
  this.replaceRecordInQueries(updatedFields);
1851
1931
 
1932
+ // Committed: see create() for why this precedes the ingest.
1933
+ this.invalidatePendingIds();
1934
+
1852
1935
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
1853
1936
 
1854
- // Save to cache
1855
- await this.cache.save(
1856
- {
1857
- table: table,
1858
- op: 'UPDATE',
1859
- record: parsedRecord,
1860
- version: target._00_rv as number,
1861
- },
1862
- true
1863
- );
1937
+ // Save to cache. Best-effort for the same reason as in create().
1938
+ try {
1939
+ await this.cache.save(
1940
+ {
1941
+ table: table,
1942
+ op: 'UPDATE',
1943
+ record: parsedRecord,
1944
+ version: target._00_rv as number,
1945
+ },
1946
+ true
1947
+ );
1948
+ } catch (err) {
1949
+ this.logger.error(
1950
+ { err, id, Category: 'sp00ky-client::DataModule::update' },
1951
+ 'SSP update-ingest failed; the row is written and queued'
1952
+ );
1953
+ }
1864
1954
 
1865
1955
  const pushEventOptions = parseUpdateOptions(id, data, options);
1866
1956
 
@@ -1875,8 +1965,6 @@ export class DataModule<S extends SchemaStructure> {
1875
1965
  options: pushEventOptions,
1876
1966
  };
1877
1967
 
1878
- // A new outbox row: the cached pending-id sets no longer describe it.
1879
- this.invalidatePendingIds();
1880
1968
  for (const callback of this.mutationCallbacks) {
1881
1969
  callback([mutationEvent]);
1882
1970
  }
@@ -1912,6 +2000,10 @@ export class DataModule<S extends SchemaStructure> {
1912
2000
 
1913
2001
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
1914
2002
 
2003
+ // Committed: the outbox row exists, drop the cached pending-id sets before
2004
+ // the re-materialize below reads them (see create()).
2005
+ this.invalidatePendingIds();
2006
+
1915
2007
  // The local DELETE has now committed. Everything below must reflect that in
1916
2008
  // active live queries — so the deleted row disappears optimistically without
1917
2009
  // a reload — even if the optimistic SSP-view ingest below fails. Previously a
@@ -1940,8 +2032,6 @@ export class DataModule<S extends SchemaStructure> {
1940
2032
  record_id: rid,
1941
2033
  };
1942
2034
 
1943
- // A new outbox row: the cached pending-id sets no longer describe it.
1944
- this.invalidatePendingIds();
1945
2035
  for (const callback of this.mutationCallbacks) {
1946
2036
  callback([mutationEvent]);
1947
2037
  }