@spooky-sync/core 0.0.1-canary.157 → 0.0.1-canary.159

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.d.ts CHANGED
@@ -802,6 +802,12 @@ interface Sp00kySyncOptions {
802
802
  * Defaults to `3`.
803
803
  */
804
804
  degradeAfterConsecutiveFailures?: number;
805
+ /**
806
+ * Max time a single mutation push may take before it is treated as a network
807
+ * failure and retried. Guards against an RPC that never settles wedging the
808
+ * up-queue for the session. Defaults to 30000; `0` disables the timeout.
809
+ */
810
+ pushTimeoutMs?: number;
805
811
  }
806
812
  /**
807
813
  * The main synchronization engine for Sp00ky.
@@ -849,6 +855,8 @@ declare class Sp00kySync<S extends SchemaStructure> {
849
855
  get pendingMutationCount(): number;
850
856
  subscribeToPendingMutations(cb: (count: number) => void): () => void;
851
857
  private readonly degradeAfterFailures;
858
+ /** Per-push RPC deadline; see {@link withPushTimeout}. */
859
+ private readonly pushTimeoutMs;
852
860
  private consecutiveSyncFailures;
853
861
  private syncHealthStatus;
854
862
  private lastSyncErrorKind;
@@ -901,6 +909,16 @@ declare class Sp00kySync<S extends SchemaStructure> {
901
909
  * leader; everything else (registration, per-query sync, poll) runs
902
910
  * against this tab's own remote session as usual. */
903
911
  demoteToFollower(forwarder: SyncForwarder): void;
912
+ /**
913
+ * A pending mutation was discarded because it can never be sent.
914
+ *
915
+ * This is a lost write, so it must not stay invisible. Every failure in this
916
+ * chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
917
+ * shows, which is how an outbox could sit undrained for hours with the UI
918
+ * reporting nothing. Surfaces as a rollback event (the mutation will never
919
+ * apply, which is what a subscriber needs to know) and degrades sync health.
920
+ */
921
+ private onMutationDropped;
904
922
  /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
905
923
  enqueueForwardedMutation(mutationId: string): Promise<void>;
906
924
  /** A relayed `_00_list_ref` LIVE event: resolve against THIS tab's queries
@@ -1002,6 +1020,19 @@ declare class Sp00kySync<S extends SchemaStructure> {
1002
1020
  * @param event The DownEvent to enqueue.
1003
1021
  */
1004
1022
  enqueueDownEvent(event: DownEvent): void;
1023
+ /**
1024
+ * Bound a mutation push so it always settles.
1025
+ *
1026
+ * `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
1027
+ * flag only clears in the `finally` of the drain loop. A push whose RPC never
1028
+ * settles (socket dropped mid-flight, response lost) therefore wedges the
1029
+ * up-queue for the rest of the session: no retry, no error, no further
1030
+ * mutation ever sent. A timeout turns that into an ordinary network failure,
1031
+ * which `UpQueue.next` re-queues for the next trigger. The message deliberately
1032
+ * contains "timed out" so `classifySyncError` treats it as `network` and
1033
+ * retries rather than rolling the mutation back.
1034
+ */
1035
+ private withPushTimeout;
1005
1036
  private processUpEvent;
1006
1037
  private handleRollback;
1007
1038
  private processDownEvent;
package/dist/index.js CHANGED
@@ -297,7 +297,7 @@ const surql = {
297
297
  },
298
298
  createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
299
299
  switch (t) {
300
- case "create": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
300
+ case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
301
301
  case "update": {
302
302
  let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
303
303
  if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
@@ -3886,12 +3886,46 @@ var UpQueue = class {
3886
3886
  get events() {
3887
3887
  return this._events;
3888
3888
  }
3889
- constructor(local, logger) {
3889
+ constructor(local, logger, onDropped) {
3890
3890
  this.local = local;
3891
+ this.onDropped = onDropped;
3891
3892
  this._events = createSyncQueueEventSystem();
3892
3893
  this.logger = logger.child({ service: "UpQueue" });
3893
3894
  this.debouncedMutations = /* @__PURE__ */ new Map();
3894
3895
  }
3896
+ /**
3897
+ * Discard an outbox row that can never be replayed and report it.
3898
+ *
3899
+ * Silently skipping such a row leaves it in the store to be re-read on every
3900
+ * boot; leaving it QUEUED is worse, since `next()` re-queues it on failure and
3901
+ * one unsendable row then blocks every later mutation for the whole app. A
3902
+ * lost write must also be loud: this is the only signal a caller gets.
3903
+ */
3904
+ async discardUnreplayable(row, reason) {
3905
+ const mutationId = typeof row?.id === "string" ? row.id : encodeRecordId(row?.id);
3906
+ this.logger.error({
3907
+ mutationId,
3908
+ recordId: row?.recordId,
3909
+ mutationType: row?.mutationType,
3910
+ reason,
3911
+ Category: "sp00ky-client::UpQueue::discardUnreplayable"
3912
+ }, "Discarding an unsendable pending mutation");
3913
+ try {
3914
+ await this.local.query(`DELETE $mutation_id`, { mutation_id: parseRecordIdString(mutationId) });
3915
+ } catch (error) {
3916
+ this.logger.error({
3917
+ error,
3918
+ mutationId,
3919
+ Category: "sp00ky-client::UpQueue::discardUnreplayable"
3920
+ }, "Failed to delete an unsendable pending mutation");
3921
+ }
3922
+ this.onDropped?.({
3923
+ mutationId,
3924
+ recordId: row?.recordId,
3925
+ mutationType: row?.mutationType,
3926
+ reason
3927
+ });
3928
+ }
3895
3929
  get size() {
3896
3930
  return this.queue.length;
3897
3931
  }
@@ -4010,21 +4044,39 @@ var UpQueue = class {
4010
4044
  async enqueueFromDatabase(mutationId) {
4011
4045
  if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
4012
4046
  try {
4013
- const [records] = await this.local.query(`SELECT * FROM $mutation_id`, { mutation_id: parseRecordIdString(mutationId) });
4014
- const event = Array.isArray(records) && records[0] ? rowToUpEvent(records[0], this.logger) : null;
4015
- if (event) this.addToQueue(event);
4047
+ const [records] = await this.local.query(`SELECT * FROM $mutation_ids`, { mutation_ids: [parseRecordIdString(mutationId)] });
4048
+ const row = Array.isArray(records) ? records[0] : void 0;
4049
+ if (!row) return;
4050
+ const event = rowToUpEvent(row, this.logger);
4051
+ if (event) {
4052
+ this.addToQueue(event);
4053
+ return;
4054
+ }
4055
+ await this.discardUnreplayable(row, "forwarded mutation is not replayable");
4016
4056
  } catch (error) {
4017
4057
  this.logger.error({
4018
4058
  error,
4019
4059
  mutationId,
4020
4060
  Category: "sp00ky-client::UpQueue::enqueueFromDatabase"
4021
4061
  }, "Failed to load a forwarded mutation");
4062
+ this.onDropped?.({
4063
+ mutationId,
4064
+ reason: error instanceof Error ? error.message : String(error)
4065
+ });
4022
4066
  }
4023
4067
  }
4024
4068
  async loadFromDatabase() {
4025
4069
  try {
4026
4070
  const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY id ASC`);
4027
- this.queue = records.map((r) => rowToUpEvent(r, this.logger)).filter((e) => e !== null);
4071
+ const loaded = [];
4072
+ const unreplayable = [];
4073
+ for (const row of records) {
4074
+ const event = rowToUpEvent(row, this.logger);
4075
+ if (event) loaded.push(event);
4076
+ else unreplayable.push(row);
4077
+ }
4078
+ this.queue = loaded;
4079
+ for (const row of unreplayable) await this.discardUnreplayable(row, "pending mutation is not replayable");
4028
4080
  } catch (error) {
4029
4081
  this.logger.error({
4030
4082
  error,
@@ -4040,13 +4092,15 @@ function encodeUpEventId(event) {
4040
4092
  /** Materialize one `_00_pending_mutations` row into an UpEvent. */
4041
4093
  function rowToUpEvent(r, logger) {
4042
4094
  switch (r.mutationType) {
4043
- case "create": return {
4044
- type: "create",
4045
- mutation_id: parseRecordIdString(r.id),
4046
- record_id: parseRecordIdString(r.recordId),
4047
- data: r.data,
4048
- tableName: extractTablePart(r.recordId)
4049
- };
4095
+ case "create":
4096
+ if (r.data === void 0 || r.data === null) return null;
4097
+ return {
4098
+ type: "create",
4099
+ mutation_id: parseRecordIdString(r.id),
4100
+ record_id: parseRecordIdString(r.recordId),
4101
+ data: r.data,
4102
+ tableName: extractTablePart(r.recordId)
4103
+ };
4050
4104
  case "update": return {
4051
4105
  type: "update",
4052
4106
  mutation_id: parseRecordIdString(r.id),
@@ -4614,6 +4668,8 @@ var Sp00kySync = class Sp00kySync {
4614
4668
  };
4615
4669
  }
4616
4670
  degradeAfterFailures;
4671
+ /** Per-push RPC deadline; see {@link withPushTimeout}. */
4672
+ pushTimeoutMs;
4617
4673
  consecutiveSyncFailures = 0;
4618
4674
  syncHealthStatus = "healthy";
4619
4675
  lastSyncErrorKind;
@@ -4740,13 +4796,14 @@ var Sp00kySync = class Sp00kySync {
4740
4796
  this.dataModule = dataModule;
4741
4797
  this.schema = schema;
4742
4798
  this.logger = logger.child({ service: "Sp00kySync" });
4743
- this.upQueue = new UpQueue(this.local, this.logger);
4799
+ this.upQueue = new UpQueue(this.local, this.logger, (dropped) => this.onMutationDropped(dropped));
4744
4800
  this.downQueue = new DownQueue(this.local, this.logger);
4745
4801
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
4746
4802
  this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this), this.recordSyncOutcome.bind(this));
4747
4803
  this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
4748
4804
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
4749
4805
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
4806
+ this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
4750
4807
  }
4751
4808
  /**
4752
4809
  * Initializes the synchronization system.
@@ -4838,6 +4895,27 @@ var Sp00kySync = class Sp00kySync {
4838
4895
  }
4839
4896
  };
4840
4897
  }
4898
+ /**
4899
+ * A pending mutation was discarded because it can never be sent.
4900
+ *
4901
+ * This is a lost write, so it must not stay invisible. Every failure in this
4902
+ * chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
4903
+ * shows, which is how an outbox could sit undrained for hours with the UI
4904
+ * reporting nothing. Surfaces as a rollback event (the mutation will never
4905
+ * apply, which is what a subscriber needs to know) and degrades sync health.
4906
+ */
4907
+ onMutationDropped(dropped) {
4908
+ this.logger.error({
4909
+ ...dropped,
4910
+ Category: "sp00ky-client::Sp00kySync::onMutationDropped"
4911
+ }, "Dropped a pending mutation that can never be sent");
4912
+ this.recordSyncOutcome(false, /* @__PURE__ */ new Error(`dropped mutation: ${dropped.reason}`));
4913
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
4914
+ eventType: dropped.mutationType ?? "update",
4915
+ recordId: dropped.recordId ?? dropped.mutationId,
4916
+ error: `dropped: ${dropped.reason}`
4917
+ });
4918
+ }
4841
4919
  /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
4842
4920
  async enqueueForwardedMutation(mutationId) {
4843
4921
  if (this.tabRole !== "leader") return;
@@ -5250,6 +5328,33 @@ var Sp00kySync = class Sp00kySync {
5250
5328
  enqueueDownEvent(event) {
5251
5329
  this.scheduler.enqueueDownEvent(event);
5252
5330
  }
5331
+ /**
5332
+ * Bound a mutation push so it always settles.
5333
+ *
5334
+ * `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
5335
+ * flag only clears in the `finally` of the drain loop. A push whose RPC never
5336
+ * settles (socket dropped mid-flight, response lost) therefore wedges the
5337
+ * up-queue for the rest of the session: no retry, no error, no further
5338
+ * mutation ever sent. A timeout turns that into an ordinary network failure,
5339
+ * which `UpQueue.next` re-queues for the next trigger. The message deliberately
5340
+ * contains "timed out" so `classifySyncError` treats it as `network` and
5341
+ * retries rather than rolling the mutation back.
5342
+ */
5343
+ withPushTimeout(promise, label) {
5344
+ if (!(this.pushTimeoutMs > 0)) return promise;
5345
+ return new Promise((resolve, reject) => {
5346
+ const timer = setTimeout(() => {
5347
+ reject(/* @__PURE__ */ new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
5348
+ }, this.pushTimeoutMs);
5349
+ promise.then((value) => {
5350
+ clearTimeout(timer);
5351
+ resolve(value);
5352
+ }, (err) => {
5353
+ clearTimeout(timer);
5354
+ reject(err);
5355
+ });
5356
+ });
5357
+ }
5253
5358
  async processUpEvent(event) {
5254
5359
  this.logger.debug({
5255
5360
  event,
@@ -5263,20 +5368,20 @@ var Sp00kySync = class Sp00kySync {
5263
5368
  }));
5264
5369
  const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, event.data[key]]));
5265
5370
  const query = surql.seal(surql.createSet("id", dataKeys));
5266
- await this.remote.query(query, {
5371
+ await this.withPushTimeout(this.remote.query(query, {
5267
5372
  id: event.record_id,
5268
5373
  ...prefixedParams
5269
- });
5374
+ }), "create");
5270
5375
  break;
5271
5376
  }
5272
5377
  case "update":
5273
- await this.remote.query(`UPDATE $id MERGE $data`, {
5378
+ await this.withPushTimeout(this.remote.query(`UPDATE $id MERGE $data`, {
5274
5379
  id: event.record_id,
5275
5380
  data: event.data
5276
- });
5381
+ }), "update");
5277
5382
  break;
5278
5383
  case "delete":
5279
- await this.remote.query(`DELETE $id`, { id: event.record_id });
5384
+ await this.withPushTimeout(this.remote.query(`DELETE $id`, { id: event.record_id }), "delete");
5280
5385
  break;
5281
5386
  default:
5282
5387
  this.logger.error({
@@ -5694,8 +5799,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5694
5799
 
5695
5800
  //#endregion
5696
5801
  //#region src/modules/devtools/index.ts
5697
- const CORE_VERSION = "0.0.1-canary.157";
5698
- const WASM_VERSION = "0.0.1-canary.157";
5802
+ const CORE_VERSION = "0.0.1-canary.159";
5803
+ const WASM_VERSION = "0.0.1-canary.159";
5699
5804
  const SURREAL_VERSION = "3.0.3";
5700
5805
  var DevToolsService = class DevToolsService {
5701
5806
  eventsHistory = [];
@@ -8939,7 +9044,7 @@ var Sp00kyClient = class {
8939
9044
  return new TabsCoordinator({
8940
9045
  tabId,
8941
9046
  fingerprint: computeTabsFingerprint({
8942
- coreVersion: "0.0.1-canary.157",
9047
+ coreVersion: "0.0.1-canary.159",
8943
9048
  schemaHash: hash53(this.config.schemaSurql),
8944
9049
  endpoint: this.config.database.endpoint ?? "",
8945
9050
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.157",
3
+ "version": "0.0.1-canary.159",
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.157",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.157",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.159",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.159",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { UpQueue } from './queue-up';
3
+ import { translateSurql } from '../../../services/database/surql-translate';
4
+ import { surql, classifySyncError } from '../../../utils/index';
3
5
 
4
6
  function makeLogger(): any {
5
7
  const noop = () => {};
@@ -21,7 +23,8 @@ const ROW = {
21
23
  describe('UpQueue.enqueueFromDatabase', () => {
22
24
  function makeQueue(rows: Record<string, unknown[]>) {
23
25
  const query = vi.fn(async (_sql: string, vars?: Record<string, unknown>) => {
24
- const id = String(vars?.mutation_id ?? '');
26
+ const ids = (vars?.mutation_ids as unknown[]) ?? [];
27
+ const id = String(ids[0] ?? '');
25
28
  return [rows[id] ?? []];
26
29
  });
27
30
  const local: any = { query };
@@ -48,4 +51,87 @@ describe('UpQueue.enqueueFromDatabase', () => {
48
51
  await queue.enqueueFromDatabase(ROW.id);
49
52
  expect(queue.size).toBe(0);
50
53
  });
54
+
55
+ // The mock above replaces `local.query` wholesale, so on its own it proves
56
+ // nothing about the SQLite engine actually being able to RUN the statement.
57
+ // That gap shipped a bug: the emitted `SELECT * FROM $mutation_id` passed a
58
+ // single RecordId where the engine's `selectByIds` lowering expects an array,
59
+ // so it threw, the surrounding catch swallowed it at `error` level, and every
60
+ // follower mutation was silently never pushed. Drive the real translator.
61
+ it('discards a create with no payload instead of queueing a guaranteed failure', async () => {
62
+ // `processUpEvent` does `Object.keys(event.data)`, so this row can never be
63
+ // sent. Queued, it sat at the HEAD and blocked every later mutation for the
64
+ // whole app: `next()` re-queues on failure, so the backlog never drained.
65
+ const dropped: any[] = [];
66
+ const rows: Record<string, unknown[]> = {
67
+ [ROW.id]: [{ id: ROW.id, mutationType: 'create', recordId: 'comment:c1' }],
68
+ };
69
+ const query = vi.fn(async (sql: string, vars?: Record<string, unknown>) => {
70
+ if (sql.startsWith('DELETE')) return [[]];
71
+ const ids = (vars?.mutation_ids as unknown[]) ?? [];
72
+ return [rows[String(ids[0] ?? '')] ?? []];
73
+ });
74
+ const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
75
+
76
+ await queue.enqueueFromDatabase(ROW.id);
77
+
78
+ expect(queue.size).toBe(0);
79
+ expect(dropped).toHaveLength(1);
80
+ // And the row is deleted, so it cannot re-poison the next boot.
81
+ expect(query.mock.calls.some(([sql]) => String(sql).startsWith('DELETE'))).toBe(true);
82
+ });
83
+
84
+ it('loadFromDatabase drains the replayable rows even when one is unsendable', async () => {
85
+ const dropped: any[] = [];
86
+ const query = vi.fn(async (sql: string) => {
87
+ if (sql.startsWith('DELETE')) return [[]];
88
+ return [
89
+ [
90
+ { id: '_00_pending_mutations:a', mutationType: 'create', recordId: 'comment:c1' },
91
+ {
92
+ id: '_00_pending_mutations:b',
93
+ mutationType: 'update',
94
+ recordId: 'game:g1',
95
+ data: { title: 'x' },
96
+ },
97
+ ],
98
+ ];
99
+ });
100
+ const queue = new UpQueue({ query } as any, makeLogger(), (d) => dropped.push(d));
101
+
102
+ await queue.loadFromDatabase();
103
+
104
+ // The good row still loads; the poison one is dropped and reported.
105
+ expect(queue.size).toBe(1);
106
+ expect(dropped).toHaveLength(1);
107
+ expect(dropped[0].mutationId).toBe('_00_pending_mutations:a');
108
+ });
109
+
110
+ it('persists the payload for a create, so it can be replayed at all', () => {
111
+ // The create branch used to accept `dataVar` and ignore it, so the outbox
112
+ // row was the ONLY copy of a pending create and it carried no data.
113
+ const stmt = surql.createMutation('create', 'mid', 'id', 'data');
114
+ expect(stmt).toContain('data = $data');
115
+ });
116
+
117
+ it('a push timeout classifies as network, so it retries instead of rolling back', () => {
118
+ const err = new Error('Mutation push timed out after 30000ms (create)');
119
+ expect(classifySyncError(err)).toBe('network');
120
+ });
121
+
122
+ it('emits a statement the SQLite engine can actually translate', async () => {
123
+ const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
124
+ await queue.enqueueFromDatabase(ROW.id);
125
+
126
+ const [sql, vars] = query.mock.calls[0] as [string, Record<string, unknown>];
127
+ const translated = translateSurql(sql, vars);
128
+ const op: any = translated.ops[0];
129
+
130
+ expect(op.kind).toBe('selectByIds');
131
+ // The engine does `ids.length` then `ids.map(...)`: a non-array silently
132
+ // skips the empty-guard and then throws.
133
+ expect(Array.isArray(op.ids)).toBe(true);
134
+ expect(op.ids).toHaveLength(1);
135
+ expect(() => (op.ids as unknown[]).map((x) => x)).not.toThrow();
136
+ });
51
137
  });
@@ -1,11 +1,7 @@
1
1
  import type { RecordId } from 'surrealdb';
2
2
  import type { LocalStore } from '../../../services/database/index';
3
- import type {
4
- SyncQueueEventSystem} from '../events/index';
5
- import {
6
- createSyncQueueEventSystem,
7
- SyncQueueEventTypes,
8
- } from '../events/index';
3
+ import type { SyncQueueEventSystem } from '../events/index';
4
+ import { createSyncQueueEventSystem, SyncQueueEventTypes } from '../events/index';
9
5
  import {
10
6
  parseRecordIdString,
11
7
  extractTablePart,
@@ -46,11 +42,27 @@ export type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
46
42
 
47
43
  export type RollbackCallback = (event: UpEvent, error: Error) => Promise<void>;
48
44
 
45
+ /**
46
+ * A pending mutation that can never be sent, so it was discarded instead of
47
+ * being retried forever at the head of the queue.
48
+ */
49
+ export type DroppedMutation = {
50
+ mutationId: string;
51
+ recordId?: string;
52
+ mutationType?: string;
53
+ reason: string;
54
+ };
55
+
56
+ export type DroppedCallback = (dropped: DroppedMutation) => void;
57
+
49
58
  export class UpQueue {
50
59
  private queue: UpEvent[] = [];
51
60
  private _events: SyncQueueEventSystem;
52
61
  private logger: Logger;
53
- private debouncedMutations: Map<string, { timer: any; firstBeforeRecord?: Record<string, unknown> }>;
62
+ private debouncedMutations: Map<
63
+ string,
64
+ { timer: any; firstBeforeRecord?: Record<string, unknown> }
65
+ >;
54
66
 
55
67
  get events(): SyncQueueEventSystem {
56
68
  return this._events;
@@ -58,13 +70,52 @@ export class UpQueue {
58
70
 
59
71
  constructor(
60
72
  private local: LocalStore,
61
- logger: Logger
73
+ logger: Logger,
74
+ private onDropped?: DroppedCallback
62
75
  ) {
63
76
  this._events = createSyncQueueEventSystem();
64
77
  this.logger = logger.child({ service: 'UpQueue' });
65
78
  this.debouncedMutations = new Map();
66
79
  }
67
80
 
81
+ /**
82
+ * Discard an outbox row that can never be replayed and report it.
83
+ *
84
+ * Silently skipping such a row leaves it in the store to be re-read on every
85
+ * boot; leaving it QUEUED is worse, since `next()` re-queues it on failure and
86
+ * one unsendable row then blocks every later mutation for the whole app. A
87
+ * lost write must also be loud: this is the only signal a caller gets.
88
+ */
89
+ private async discardUnreplayable(row: any, reason: string): Promise<void> {
90
+ const mutationId = typeof row?.id === 'string' ? row.id : encodeRecordId(row?.id);
91
+ this.logger.error(
92
+ {
93
+ mutationId,
94
+ recordId: row?.recordId,
95
+ mutationType: row?.mutationType,
96
+ reason,
97
+ Category: 'sp00ky-client::UpQueue::discardUnreplayable',
98
+ },
99
+ 'Discarding an unsendable pending mutation'
100
+ );
101
+ try {
102
+ await this.local.query(`DELETE $mutation_id`, {
103
+ mutation_id: parseRecordIdString(mutationId),
104
+ });
105
+ } catch (error) {
106
+ this.logger.error(
107
+ { error, mutationId, Category: 'sp00ky-client::UpQueue::discardUnreplayable' },
108
+ 'Failed to delete an unsendable pending mutation'
109
+ );
110
+ }
111
+ this.onDropped?.({
112
+ mutationId,
113
+ recordId: row?.recordId,
114
+ mutationType: row?.mutationType,
115
+ reason,
116
+ });
117
+ }
118
+
68
119
  get size(): number {
69
120
  return this.queue.length;
70
121
  }
@@ -199,16 +250,34 @@ export class UpQueue {
199
250
  async enqueueFromDatabase(mutationId: string): Promise<void> {
200
251
  if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
201
252
  try {
202
- const [records] = await this.local.query<any>(`SELECT * FROM $mutation_id`, {
203
- mutation_id: parseRecordIdString(mutationId),
253
+ // ARRAY param, matching SyncEngine's `SELECT * FROM $idsToFetch`. A bare
254
+ // `FROM $singleRecordId` looks fine against SurrealDB but the SQLite
255
+ // engine lowers any `FROM $param` to `selectByIds` and calls `.map` on
256
+ // the param (surql-translate.ts, SqliteCacheEngine.selectByIds), so a
257
+ // single RecordId threw. The throw landed in the catch below, which logs
258
+ // at `error` — invisible to an app running `logLevel: 'fatal'` — so every
259
+ // forwarded mutation was silently dropped: the follower's optimistic
260
+ // write stuck locally, was never pushed, and the next down-sync reverted it.
261
+ const [records] = await this.local.query<any>(`SELECT * FROM $mutation_ids`, {
262
+ mutation_ids: [parseRecordIdString(mutationId)],
204
263
  });
205
- const event = Array.isArray(records) && records[0] ? rowToUpEvent(records[0], this.logger) : null;
206
- if (event) this.addToQueue(event);
264
+ const row = Array.isArray(records) ? records[0] : undefined;
265
+ if (!row) return;
266
+ const event = rowToUpEvent(row, this.logger);
267
+ if (event) {
268
+ this.addToQueue(event);
269
+ return;
270
+ }
271
+ await this.discardUnreplayable(row, 'forwarded mutation is not replayable');
207
272
  } catch (error) {
208
273
  this.logger.error(
209
274
  { error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
210
275
  'Failed to load a forwarded mutation'
211
276
  );
277
+ this.onDropped?.({
278
+ mutationId,
279
+ reason: error instanceof Error ? error.message : String(error),
280
+ });
212
281
  }
213
282
  }
214
283
 
@@ -222,9 +291,20 @@ export class UpQueue {
222
291
  `SELECT * FROM _00_pending_mutations ORDER BY id ASC`
223
292
  );
224
293
 
225
- this.queue = records
226
- .map((r: any): UpEvent | null => rowToUpEvent(r, this.logger))
227
- .filter((e: UpEvent | null): e is UpEvent => e !== null);
294
+ const loaded: UpEvent[] = [];
295
+ const unreplayable: any[] = [];
296
+ for (const row of records as any[]) {
297
+ const event = rowToUpEvent(row, this.logger);
298
+ if (event) loaded.push(event);
299
+ else unreplayable.push(row);
300
+ }
301
+ this.queue = loaded;
302
+ // Drop them AFTER the queue is populated: one unsendable row must not
303
+ // stop the rest of the backlog from draining, and leaving it in the store
304
+ // would just re-poison the next boot.
305
+ for (const row of unreplayable) {
306
+ await this.discardUnreplayable(row, 'pending mutation is not replayable');
307
+ }
228
308
  } catch (error) {
229
309
  this.logger.error(
230
310
  { error, Category: 'sp00ky-client::UpQueue::loadFromDatabase' },
@@ -243,6 +323,12 @@ function encodeUpEventId(event: UpEvent): string {
243
323
  function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
244
324
  switch (r.mutationType) {
245
325
  case 'create':
326
+ // `processUpEvent` does `Object.keys(event.data)`, so a create with no
327
+ // payload throws before it reaches the network and can never succeed.
328
+ // Rows written before the create branch of `surql.createMutation`
329
+ // persisted `data` are exactly that, so refuse them here instead of
330
+ // queueing a guaranteed failure.
331
+ if (r.data === undefined || r.data === null) return null;
246
332
  return {
247
333
  type: 'create',
248
334
  mutation_id: parseRecordIdString(r.id),
@@ -266,7 +352,11 @@ function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
266
352
  };
267
353
  default:
268
354
  logger.warn(
269
- { mutationType: r.mutationType, record: r, Category: 'sp00ky-client::UpQueue::rowToUpEvent' },
355
+ {
356
+ mutationType: r.mutationType,
357
+ record: r,
358
+ Category: 'sp00ky-client::UpQueue::rowToUpEvent',
359
+ },
270
360
  'Unknown mutation type'
271
361
  );
272
362
  return null;
@@ -1,8 +1,13 @@
1
1
  import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
2
- import type { RecordVersionArray, RecordVersionDiff, SyncHealth, SyncHealthStatus } from '../../types';
2
+ import type {
3
+ RecordVersionArray,
4
+ RecordVersionDiff,
5
+ SyncHealth,
6
+ SyncHealthStatus,
7
+ } from '../../types';
3
8
  import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
4
9
  import type { Logger } from '../../services/logger/index';
5
- import type { DownEvent, UpEvent} from './queue/index';
10
+ import type { DownEvent, UpEvent } from './queue/index';
6
11
  import { DownQueue, UpQueue } from './queue/index';
7
12
  import type { RecordId, Uuid } from 'surrealdb';
8
13
  import {
@@ -20,7 +25,13 @@ import { SyncScheduler } from './scheduler';
20
25
  import type { SchemaStructure } from '@spooky-sync/query-builder';
21
26
  import type { CacheModule } from '../cache/index';
22
27
  import type { DataModule } from '../data/index';
23
- import { classifySyncError, encodeRecordId, extractIdPart, extractTablePart, surql } from '../../utils/index';
28
+ import {
29
+ classifySyncError,
30
+ encodeRecordId,
31
+ extractIdPart,
32
+ extractTablePart,
33
+ surql,
34
+ } from '../../utils/index';
24
35
  import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor, RefMode } from '../ref-tables';
25
36
  import { mutationOwnerTabId } from '../data/mutation-id';
26
37
  import type { LeaderSyncHub, SyncForwarder } from '../../services/tabs/coordinator';
@@ -49,6 +60,12 @@ export interface Sp00kySyncOptions {
49
60
  * Defaults to `3`.
50
61
  */
51
62
  degradeAfterConsecutiveFailures?: number;
63
+ /**
64
+ * Max time a single mutation push may take before it is treated as a network
65
+ * failure and retried. Guards against an RPC that never settles wedging the
66
+ * up-queue for the session. Defaults to 30000; `0` disables the timeout.
67
+ */
68
+ pushTimeoutMs?: number;
52
69
  }
53
70
 
54
71
  /**
@@ -162,13 +179,11 @@ export class Sp00kySync<S extends SchemaStructure> {
162
179
  }
163
180
 
164
181
  subscribeToPendingMutations(cb: (count: number) => void): () => void {
165
- const id1 = this.upQueue.events.subscribe(
166
- SyncQueueEventTypes.MutationEnqueued,
167
- (event) => cb(event.payload.queueSize)
182
+ const id1 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, (event) =>
183
+ cb(event.payload.queueSize)
168
184
  );
169
- const id2 = this.upQueue.events.subscribe(
170
- SyncQueueEventTypes.MutationDequeued,
171
- (event) => cb(event.payload.queueSize)
185
+ const id2 = this.upQueue.events.subscribe(SyncQueueEventTypes.MutationDequeued, (event) =>
186
+ cb(event.payload.queueSize)
172
187
  );
173
188
  return () => {
174
189
  this.upQueue.events.unsubscribe(id1);
@@ -180,6 +195,8 @@ export class Sp00kySync<S extends SchemaStructure> {
180
195
  // `0` disables degraded reporting (config `syncHealth: false`). Resolved
181
196
  // from config in Sp00kyClient and passed through the constructor options.
182
197
  private readonly degradeAfterFailures: number;
198
+ /** Per-push RPC deadline; see {@link withPushTimeout}. */
199
+ private readonly pushTimeoutMs: number;
183
200
  private consecutiveSyncFailures = 0;
184
201
  private syncHealthStatus: SyncHealthStatus = 'healthy';
185
202
  private lastSyncErrorKind: 'network' | 'application' | undefined;
@@ -297,7 +314,11 @@ export class Sp00kySync<S extends SchemaStructure> {
297
314
  if (this.syncHealthStatus !== 'degraded') return;
298
315
  this.selfHealAttempts++;
299
316
  this.logger.debug(
300
- { attempt: this.selfHealAttempts, delayMs: delay, Category: 'sp00ky-client::Sp00kySync::selfHeal' },
317
+ {
318
+ attempt: this.selfHealAttempts,
319
+ delayMs: delay,
320
+ Category: 'sp00ky-client::Sp00kySync::selfHeal',
321
+ },
301
322
  'Self-heal: re-driving sync while degraded'
302
323
  );
303
324
  try {
@@ -353,7 +374,9 @@ export class Sp00kySync<S extends SchemaStructure> {
353
374
  options?: Sp00kySyncOptions
354
375
  ) {
355
376
  this.logger = logger.child({ service: 'Sp00kySync' });
356
- this.upQueue = new UpQueue(this.local, this.logger);
377
+ this.upQueue = new UpQueue(this.local, this.logger, (dropped) =>
378
+ this.onMutationDropped(dropped)
379
+ );
357
380
  this.downQueue = new DownQueue(this.local, this.logger);
358
381
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
359
382
  this.scheduler = new SyncScheduler(
@@ -368,6 +391,7 @@ export class Sp00kySync<S extends SchemaStructure> {
368
391
  this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
369
392
  this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
370
393
  this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
394
+ this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 30_000);
371
395
  }
372
396
 
373
397
  /**
@@ -483,6 +507,33 @@ export class Sp00kySync<S extends SchemaStructure> {
483
507
  };
484
508
  }
485
509
 
510
+ /**
511
+ * A pending mutation was discarded because it can never be sent.
512
+ *
513
+ * This is a lost write, so it must not stay invisible. Every failure in this
514
+ * chain used to be a `logger.error` an app running `logLevel: 'fatal'` never
515
+ * shows, which is how an outbox could sit undrained for hours with the UI
516
+ * reporting nothing. Surfaces as a rollback event (the mutation will never
517
+ * apply, which is what a subscriber needs to know) and degrades sync health.
518
+ */
519
+ private onMutationDropped(dropped: {
520
+ mutationId: string;
521
+ recordId?: string;
522
+ mutationType?: string;
523
+ reason: string;
524
+ }): void {
525
+ this.logger.error(
526
+ { ...dropped, Category: 'sp00ky-client::Sp00kySync::onMutationDropped' },
527
+ 'Dropped a pending mutation that can never be sent'
528
+ );
529
+ this.recordSyncOutcome(false, new Error(`dropped mutation: ${dropped.reason}`));
530
+ this.events.emit(SyncEventTypes.MutationRolledBack, {
531
+ eventType: (dropped.mutationType as 'create' | 'update' | 'delete') ?? 'update',
532
+ recordId: dropped.recordId ?? dropped.mutationId,
533
+ error: `dropped: ${dropped.reason}`,
534
+ });
535
+ }
536
+
486
537
  /** A forwarded outbox row from a follower: load + drain it. Idempotent. */
487
538
  public async enqueueForwardedMutation(mutationId: string): Promise<void> {
488
539
  if (this.tabRole !== 'leader') return;
@@ -709,7 +760,11 @@ export class Sp00kySync<S extends SchemaStructure> {
709
760
  reached = true;
710
761
  }
711
762
  this.logger.debug(
712
- { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
763
+ {
764
+ err: (err as Error)?.message ?? err,
765
+ hash,
766
+ Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries',
767
+ },
713
768
  'Per-query list_ref poll failed'
714
769
  );
715
770
  }
@@ -742,10 +797,7 @@ export class Sp00kySync<S extends SchemaStructure> {
742
797
  { in: queryState.config.id }
743
798
  );
744
799
  if (!Array.isArray(items)) return false;
745
- const fresh: RecordVersionArray = items.map((item) => [
746
- encodeRecordId(item.out),
747
- item.version,
748
- ]);
800
+ const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
749
801
  // Capture which ids LEFT the query's window (present in the cached
750
802
  // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
751
803
  // are cross-window deletes (or rows that scrolled out). They drive the
@@ -780,7 +832,11 @@ export class Sp00kySync<S extends SchemaStructure> {
780
832
  await this.syncQuery(queryHash);
781
833
  } catch (err) {
782
834
  this.logger.info(
783
- { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
835
+ {
836
+ err: (err as Error)?.message ?? err,
837
+ queryHash,
838
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
839
+ },
784
840
  'syncQuery failed during poll'
785
841
  );
786
842
  }
@@ -789,7 +845,11 @@ export class Sp00kySync<S extends SchemaStructure> {
789
845
  // poll too (idempotent — no-op when nothing changed).
790
846
  await this.syncSubqueryChildren(queryHash).catch((err) => {
791
847
  this.logger.info(
792
- { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
848
+ {
849
+ err: (err as Error)?.message ?? err,
850
+ queryHash,
851
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
852
+ },
793
853
  'Subquery child sync failed during poll'
794
854
  );
795
855
  });
@@ -803,7 +863,11 @@ export class Sp00kySync<S extends SchemaStructure> {
803
863
  await this.dataModule.notifyQuerySynced(queryHash);
804
864
  } catch (err) {
805
865
  this.logger.info(
806
- { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
866
+ {
867
+ err: (err as Error)?.message ?? err,
868
+ queryHash,
869
+ Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery',
870
+ },
807
871
  'notifyQuerySynced failed during poll-removal re-render'
808
872
  );
809
873
  }
@@ -833,7 +897,11 @@ export class Sp00kySync<S extends SchemaStructure> {
833
897
 
834
898
  private async killRefLiveQuery(): Promise<void> {
835
899
  if (this.liveQueryUnsubscribe) {
836
- try { this.liveQueryUnsubscribe(); } catch { /* ignore */ }
900
+ try {
901
+ this.liveQueryUnsubscribe();
902
+ } catch {
903
+ /* ignore */
904
+ }
837
905
  this.liveQueryUnsubscribe = null;
838
906
  }
839
907
  if (this.currentLiveQueryUuid !== null) {
@@ -903,9 +971,7 @@ export class Sp00kySync<S extends SchemaStructure> {
903
971
  'Starting ref live queries'
904
972
  );
905
973
 
906
- const [queryUuid] = await this.remote.query<[Uuid]>(
907
- `LIVE SELECT * FROM ${tableName}`
908
- );
974
+ const [queryUuid] = await this.remote.query<[Uuid]>(`LIVE SELECT * FROM ${tableName}`);
909
975
  this.currentLiveQueryUuid = queryUuid;
910
976
 
911
977
  const live = await this.remote.getClient().liveOf(queryUuid);
@@ -1077,6 +1143,37 @@ export class Sp00kySync<S extends SchemaStructure> {
1077
1143
  this.scheduler.enqueueDownEvent(event);
1078
1144
  }
1079
1145
 
1146
+ /**
1147
+ * Bound a mutation push so it always settles.
1148
+ *
1149
+ * `SyncScheduler.syncUp` early-returns while `isSyncingUp` is true, and that
1150
+ * flag only clears in the `finally` of the drain loop. A push whose RPC never
1151
+ * settles (socket dropped mid-flight, response lost) therefore wedges the
1152
+ * up-queue for the rest of the session: no retry, no error, no further
1153
+ * mutation ever sent. A timeout turns that into an ordinary network failure,
1154
+ * which `UpQueue.next` re-queues for the next trigger. The message deliberately
1155
+ * contains "timed out" so `classifySyncError` treats it as `network` and
1156
+ * retries rather than rolling the mutation back.
1157
+ */
1158
+ private withPushTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
1159
+ if (!(this.pushTimeoutMs > 0)) return promise;
1160
+ return new Promise<T>((resolve, reject) => {
1161
+ const timer = setTimeout(() => {
1162
+ reject(new Error(`Mutation push timed out after ${this.pushTimeoutMs}ms (${label})`));
1163
+ }, this.pushTimeoutMs);
1164
+ promise.then(
1165
+ (value) => {
1166
+ clearTimeout(timer);
1167
+ resolve(value);
1168
+ },
1169
+ (err) => {
1170
+ clearTimeout(timer);
1171
+ reject(err);
1172
+ }
1173
+ );
1174
+ });
1175
+ }
1176
+
1080
1177
  private async processUpEvent(event: UpEvent) {
1081
1178
  this.logger.debug(
1082
1179
  { event, Category: 'sp00ky-client::Sp00kySync::processUpEvent' },
@@ -1089,22 +1186,31 @@ export class Sp00kySync<S extends SchemaStructure> {
1089
1186
  dataKeys.map(({ key, variable }) => [variable, event.data[key]])
1090
1187
  );
1091
1188
  const query = surql.seal(surql.createSet('id', dataKeys));
1092
- await this.remote.query(query, {
1093
- id: event.record_id,
1094
- ...prefixedParams,
1095
- });
1189
+ await this.withPushTimeout(
1190
+ this.remote.query(query, {
1191
+ id: event.record_id,
1192
+ ...prefixedParams,
1193
+ }),
1194
+ 'create'
1195
+ );
1096
1196
  break;
1097
1197
  }
1098
1198
  case 'update':
1099
- await this.remote.query(`UPDATE $id MERGE $data`, {
1100
- id: event.record_id,
1101
- data: event.data,
1102
- });
1199
+ await this.withPushTimeout(
1200
+ this.remote.query(`UPDATE $id MERGE $data`, {
1201
+ id: event.record_id,
1202
+ data: event.data,
1203
+ }),
1204
+ 'update'
1205
+ );
1103
1206
  break;
1104
1207
  case 'delete':
1105
- await this.remote.query(`DELETE $id`, {
1106
- id: event.record_id,
1107
- });
1208
+ await this.withPushTimeout(
1209
+ this.remote.query(`DELETE $id`, {
1210
+ id: event.record_id,
1211
+ }),
1212
+ 'delete'
1213
+ );
1108
1214
  break;
1109
1215
  default:
1110
1216
  this.logger.error(
@@ -1118,9 +1224,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1118
1224
  private async handleRollback(event: UpEvent, error: Error): Promise<void> {
1119
1225
  const recordId = encodeRecordId(event.record_id);
1120
1226
  const tableName =
1121
- event.type === 'create' && event.tableName
1122
- ? event.tableName
1123
- : extractTablePart(recordId);
1227
+ event.type === 'create' && event.tableName ? event.tableName : extractTablePart(recordId);
1124
1228
 
1125
1229
  this.logger.warn(
1126
1230
  {
@@ -1448,7 +1552,11 @@ export class Sp00kySync<S extends SchemaStructure> {
1448
1552
  // empty. Best-effort: never fail registration over it.
1449
1553
  await this.syncSubqueryChildren(queryHash).catch((err) => {
1450
1554
  this.logger.info(
1451
- { err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
1555
+ {
1556
+ err: (err as Error)?.message ?? err,
1557
+ queryHash,
1558
+ Category: 'sp00ky-client::Sp00kySync::createRemoteQuery',
1559
+ },
1452
1560
  'Subquery child sync failed during registration; poll will retry'
1453
1561
  );
1454
1562
  });
@@ -91,17 +91,9 @@ export const surql: SurqlHelper = {
91
91
  returnValues: ({ field: string; alias: string } | string)[]
92
92
  ) {
93
93
  return `SELECT ${returnValues
94
- .map((rv) =>
95
- typeof rv === 'string'
96
- ? rv
97
- : `${rv.field} as ${rv.alias}`
98
- )
94
+ .map((rv) => (typeof rv === 'string' ? rv : `${rv.field} as ${rv.alias}`))
99
95
  .join(',')} FROM ${table} WHERE ${whereVar
100
- .map((wv) =>
101
- typeof wv === 'string'
102
- ? `${wv} = $${wv}`
103
- : `${wv.field} = $${wv.variable}`
104
- )
96
+ .map((wv) => (typeof wv === 'string' ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`))
105
97
  .join(' AND ')}`;
106
98
  },
107
99
 
@@ -172,7 +164,18 @@ export const surql: SurqlHelper = {
172
164
  ) {
173
165
  switch (t) {
174
166
  case 'create':
175
- return `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
167
+ // `data` is REQUIRED here, not decorative. The outbox row is the only
168
+ // copy of a pending create once the in-memory UpEvent is gone (reload,
169
+ // or a shared-tabs follower whose row is replayed by the leader). This
170
+ // used to drop `dataVar` on the floor, so every replayed create arrived
171
+ // with `data: undefined` and `processUpEvent` threw on
172
+ // `Object.keys(event.data)` before it ever reached the network: the
173
+ // create was unsendable AND it sat at the head of the queue blocking
174
+ // every later mutation. The payload was simply never persisted, so
175
+ // those creates were unrecoverable.
176
+ return dataVar
177
+ ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}`
178
+ : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
176
179
  case 'update': {
177
180
  let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
178
181
  if (beforeRecordVar) {