@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

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.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -1,13 +1,15 @@
1
1
  import { RecordId } from 'surrealdb';
2
- import { LocalDatabaseService } from '../../../services/database/index';
2
+ import type { LocalStore } from '../../../services/database/index';
3
+ import type { SyncQueueEventSystem } from '../events/index';
4
+ import { createSyncQueueEventSystem, SyncQueueEventTypes } from '../events/index';
3
5
  import {
4
- createSyncQueueEventSystem,
5
- SyncQueueEventSystem,
6
- SyncQueueEventTypes,
7
- } from '../events/index';
8
- import { parseRecordIdString, extractTablePart, classifySyncError } from '../../../utils/index';
9
- import { Logger } from '../../../services/logger/index';
10
- import { PushEventOptions } from '../../../events/index';
6
+ parseRecordIdString,
7
+ extractTablePart,
8
+ classifySyncError,
9
+ encodeRecordId,
10
+ } from '../../../utils/index';
11
+ import type { Logger } from '../../../services/logger/index';
12
+ import type { PushEventOptions } from '../../../events/index';
11
13
 
12
14
  export type CreateEvent = {
13
15
  type: 'create';
@@ -40,25 +42,80 @@ export type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
40
42
 
41
43
  export type RollbackCallback = (event: UpEvent, error: Error) => Promise<void>;
42
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
+
43
58
  export class UpQueue {
44
59
  private queue: UpEvent[] = [];
45
60
  private _events: SyncQueueEventSystem;
46
61
  private logger: Logger;
47
- private debouncedMutations: Map<string, { timer: any; firstBeforeRecord?: Record<string, unknown> }>;
62
+ private debouncedMutations: Map<
63
+ string,
64
+ { timer: any; firstBeforeRecord?: Record<string, unknown> }
65
+ >;
48
66
 
49
67
  get events(): SyncQueueEventSystem {
50
68
  return this._events;
51
69
  }
52
70
 
53
71
  constructor(
54
- private local: LocalDatabaseService,
55
- logger: Logger
72
+ private local: LocalStore,
73
+ logger: Logger,
74
+ private onDropped?: DroppedCallback
56
75
  ) {
57
76
  this._events = createSyncQueueEventSystem();
58
77
  this.logger = logger.child({ service: 'UpQueue' });
59
78
  this.debouncedMutations = new Map();
60
79
  }
61
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: parseStoredRecordId(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
+
62
119
  get size(): number {
63
120
  return this.queue.length;
64
121
  }
@@ -104,7 +161,32 @@ export class UpQueue {
104
161
  this.debouncedMutations.set(key, { timer, firstBeforeRecord });
105
162
  }
106
163
 
107
- async next(fn: (event: UpEvent) => Promise<void>, onRollback?: RollbackCallback): Promise<void> {
164
+ /**
165
+ * Cancel all pending debounce timers WITHOUT enqueueing their events. Used
166
+ * on local-bucket switches: the mutation's `_00_pending_mutations` row was
167
+ * already persisted at mutation time, so dropping the in-memory push only
168
+ * defers it to that bucket's next `loadFromDatabase` — it must NOT be pushed
169
+ * now, the remote session already belongs to the next user.
170
+ */
171
+ clearDebounceTimers(): void {
172
+ for (const { timer } of this.debouncedMutations.values()) {
173
+ clearTimeout(timer);
174
+ }
175
+ this.debouncedMutations.clear();
176
+ }
177
+
178
+ /**
179
+ * @param onSettled Reports a mutation the server ACCEPTED, after its outbox
180
+ * row is gone. Deliberately not called on the rollback path: a rejected
181
+ * mutation must stop being rendered immediately, while an accepted one has
182
+ * to stay visible until its membership arrives (see
183
+ * `DataModule.noteWriteSettled`).
184
+ */
185
+ async next(
186
+ fn: (event: UpEvent) => Promise<void>,
187
+ onRollback?: RollbackCallback,
188
+ onSettled?: (event: UpEvent) => void
189
+ ): Promise<void> {
108
190
  const event = this.queue.shift();
109
191
  if (event) {
110
192
  try {
@@ -114,7 +196,7 @@ export class UpQueue {
114
196
 
115
197
  if (errorType === 'network') {
116
198
  this.logger.error(
117
- { error, event, Category: 'spooky-client::UpQueue::next' },
199
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
118
200
  'Network error processing mutation, re-queuing'
119
201
  );
120
202
  this.queue.unshift(event);
@@ -123,14 +205,14 @@ export class UpQueue {
123
205
 
124
206
  // Application error — rollback instead of re-queuing
125
207
  this.logger.error(
126
- { error, event, Category: 'spooky-client::UpQueue::next' },
208
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
127
209
  'Application error processing mutation, rolling back'
128
210
  );
129
211
  try {
130
212
  await this.removeEventFromDatabase(event.mutation_id);
131
213
  } catch (removeError) {
132
214
  this.logger.error(
133
- { error: removeError, event, Category: 'spooky-client::UpQueue::next' },
215
+ { error: removeError, event, Category: 'sp00ky-client::UpQueue::next' },
134
216
  'Failed to remove rolled-back mutation from database'
135
217
  );
136
218
  }
@@ -139,7 +221,7 @@ export class UpQueue {
139
221
  await onRollback(event, error instanceof Error ? error : new Error(String(error)));
140
222
  } catch (rollbackError) {
141
223
  this.logger.error(
142
- { error: rollbackError, event, Category: 'spooky-client::UpQueue::next' },
224
+ { error: rollbackError, event, Category: 'sp00ky-client::UpQueue::next' },
143
225
  'Rollback handler failed'
144
226
  );
145
227
  }
@@ -154,10 +236,23 @@ export class UpQueue {
154
236
  await this.removeEventFromDatabase(event.mutation_id);
155
237
  } catch (error) {
156
238
  this.logger.error(
157
- { error, event, Category: 'spooky-client::UpQueue::next' },
239
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
158
240
  'Failed to remove mutation from database after successful processing'
159
241
  );
160
242
  }
243
+ // Report AFTER the outbox row is gone: that delete is exactly what drops
244
+ // the row out of the render set, so this is the moment the grace window
245
+ // has to start covering.
246
+ if (onSettled) {
247
+ try {
248
+ onSettled(event);
249
+ } catch (error) {
250
+ this.logger.error(
251
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
252
+ 'Settled-write handler failed'
253
+ );
254
+ }
255
+ }
161
256
  this._events.addEvent({
162
257
  type: SyncQueueEventTypes.MutationDequeued,
163
258
  payload: { queueSize: this.queue.length },
@@ -169,55 +264,144 @@ export class UpQueue {
169
264
  return this.local.query(`DELETE $mutation_id`, { mutation_id });
170
265
  }
171
266
 
267
+ /**
268
+ * Shared-tabs: a follower committed an outbox row into the shared store and
269
+ * notified the leader; enqueue exactly that row. Idempotent: a replayed
270
+ * notify (failover) or a row already queued is a no-op, and a row that never
271
+ * committed simply is not found (the follower's own write promise already
272
+ * rejected in that case).
273
+ */
274
+ async enqueueFromDatabase(mutationId: string): Promise<void> {
275
+ if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
276
+ try {
277
+ // ARRAY param, matching SyncEngine's `SELECT * FROM $idsToFetch`. A bare
278
+ // `FROM $singleRecordId` looks fine against SurrealDB but the SQLite
279
+ // engine lowers any `FROM $param` to `selectByIds` and calls `.map` on
280
+ // the param (surql-translate.ts, SqliteCacheEngine.selectByIds), so a
281
+ // single RecordId threw. The throw landed in the catch below, which logs
282
+ // at `error` — invisible to an app running `logLevel: 'fatal'` — so every
283
+ // forwarded mutation was silently dropped: the follower's optimistic
284
+ // write stuck locally, was never pushed, and the next down-sync reverted it.
285
+ const [records] = await this.local.query<any>(`SELECT * FROM $mutation_ids`, {
286
+ mutation_ids: [parseRecordIdString(mutationId)],
287
+ });
288
+ const row = Array.isArray(records) ? records[0] : undefined;
289
+ if (!row) return;
290
+ const event = rowToUpEvent(row, this.logger);
291
+ if (event) {
292
+ this.addToQueue(event);
293
+ return;
294
+ }
295
+ await this.discardUnreplayable(row, 'forwarded mutation is not replayable');
296
+ } catch (error) {
297
+ this.logger.error(
298
+ { error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
299
+ 'Failed to load a forwarded mutation'
300
+ );
301
+ this.onDropped?.({
302
+ mutationId,
303
+ reason: error instanceof Error ? error.message : String(error),
304
+ });
305
+ }
306
+ }
307
+
172
308
  async loadFromDatabase() {
173
309
  try {
310
+ // ORDER BY id: mutation ids are zero-padded-timestamp-prefixed (see
311
+ // modules/data/mutation-id.ts), so lexicographic id order IS chronological
312
+ // order. (The previous created_at was never a defined field on this
313
+ // table, so the old ordering was undefined.)
174
314
  const [records] = await this.local.query<any>(
175
- `SELECT * FROM _spooky_pending_mutations ORDER BY created_at ASC`
315
+ `SELECT * FROM _00_pending_mutations ORDER BY id ASC`
176
316
  );
177
317
 
178
- this.queue = records
179
- .map((r: any): UpEvent | null => {
180
- switch (r.mutationType) {
181
- case 'create':
182
- return {
183
- type: 'create',
184
- mutation_id: parseRecordIdString(r.id),
185
- record_id: parseRecordIdString(r.recordId),
186
- data: r.data,
187
- tableName: extractTablePart(r.recordId),
188
- };
189
- case 'update':
190
- return {
191
- type: 'update',
192
- mutation_id: parseRecordIdString(r.id),
193
- record_id: parseRecordIdString(r.recordId),
194
- data: r.data,
195
- beforeRecord: r.beforeRecord,
196
- };
197
- case 'delete':
198
- return {
199
- type: 'delete',
200
- mutation_id: parseRecordIdString(r.id),
201
- record_id: parseRecordIdString(r.recordId),
202
- };
203
- default:
204
- this.logger.warn(
205
- {
206
- mutationType: r.mutationType,
207
- record: r,
208
- Category: 'spooky-client::UpQueue::loadFromDatabase',
209
- },
210
- 'Unknown mutation type'
211
- );
212
- return null;
213
- }
214
- })
215
- .filter((e: UpEvent | null): e is UpEvent => e !== null);
318
+ const loaded: UpEvent[] = [];
319
+ const unreplayable: any[] = [];
320
+ for (const row of records as any[]) {
321
+ const event = rowToUpEvent(row, this.logger);
322
+ if (event) loaded.push(event);
323
+ else unreplayable.push(row);
324
+ }
325
+ this.queue = loaded;
326
+ // Drop them AFTER the queue is populated: one unsendable row must not
327
+ // stop the rest of the backlog from draining, and leaving it in the store
328
+ // would just re-poison the next boot.
329
+ for (const row of unreplayable) {
330
+ await this.discardUnreplayable(row, 'pending mutation is not replayable');
331
+ }
216
332
  } catch (error) {
217
333
  this.logger.error(
218
- { error, Category: 'spooky-client::UpQueue::loadFromDatabase' },
334
+ { error, Category: 'sp00ky-client::UpQueue::loadFromDatabase' },
219
335
  'Failed to load pending mutations from database'
220
336
  );
221
337
  }
222
338
  }
223
339
  }
340
+
341
+ /** The stable string id of an event's outbox row. */
342
+ function encodeUpEventId(event: UpEvent): string {
343
+ return encodeRecordId(event.mutation_id);
344
+ }
345
+
346
+ /**
347
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
348
+ * escaping.
349
+ *
350
+ * Outbox ids contain `_` and `-`, so the store hands them back in escaped
351
+ * display form (`_00_pending_mutations:⟨1785…_0005_c960…⟩`). `parseRecordIdString`
352
+ * keeps that verbatim, and re-encoding then escapes the brackets AGAIN, so a
353
+ * `DELETE $mutation_id` built from a stored id targets
354
+ * `_00_pending_mutations:⟨⟨1785…\⟩⟩` and matches nothing. Every mutation
355
+ * replayed from the store was therefore un-deletable: its row survived a
356
+ * SUCCESSFUL push and got re-sent on the next boot.
357
+ */
358
+ function parseStoredRecordId(id: string): RecordId<string> {
359
+ const [table, ...rest] = id.split(':');
360
+ let raw = rest.join(':');
361
+ if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
362
+ return new RecordId(table, raw);
363
+ }
364
+
365
+ /** Materialize one `_00_pending_mutations` row into an UpEvent. */
366
+ function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
367
+ switch (r.mutationType) {
368
+ case 'create':
369
+ // `processUpEvent` does `Object.keys(event.data)`, so a create with no
370
+ // payload throws before it reaches the network and can never succeed.
371
+ // Rows written before the create branch of `surql.createMutation`
372
+ // persisted `data` are exactly that, so refuse them here instead of
373
+ // queueing a guaranteed failure.
374
+ if (r.data === undefined || r.data === null) return null;
375
+ return {
376
+ type: 'create',
377
+ mutation_id: parseStoredRecordId(r.id),
378
+ record_id: parseRecordIdString(r.recordId),
379
+ data: r.data,
380
+ tableName: extractTablePart(r.recordId),
381
+ };
382
+ case 'update':
383
+ return {
384
+ type: 'update',
385
+ mutation_id: parseStoredRecordId(r.id),
386
+ record_id: parseRecordIdString(r.recordId),
387
+ data: r.data,
388
+ beforeRecord: r.beforeRecord,
389
+ };
390
+ case 'delete':
391
+ return {
392
+ type: 'delete',
393
+ mutation_id: parseStoredRecordId(r.id),
394
+ record_id: parseRecordIdString(r.recordId),
395
+ };
396
+ default:
397
+ logger.warn(
398
+ {
399
+ mutationType: r.mutationType,
400
+ record: r,
401
+ Category: 'sp00ky-client::UpQueue::rowToUpEvent',
402
+ },
403
+ 'Unknown mutation type'
404
+ );
405
+ return null;
406
+ }
407
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { SyncScheduler } from './scheduler';
3
+ import type { UpQueue, DownQueue, UpEvent } from './queue/index';
4
+
5
+ // `SyncScheduler.pause()` is the drain step of a local-bucket switch: it must
6
+ // (a) let an IN-FLIGHT queue item finish — including its outbox-row delete,
7
+ // which has to land in the old bucket — and only then resolve, and (b) refuse
8
+ // to start new rounds until `resume()`. The pause point is between items.
9
+
10
+ const silentLogger = {
11
+ child: () => silentLogger,
12
+ debug: () => {},
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ } as any;
17
+
18
+ function makeQueues(items: UpEvent[]) {
19
+ const upQueue = {
20
+ queue: [...items],
21
+ get size() {
22
+ return this.queue.length;
23
+ },
24
+ events: { subscribe: () => {} },
25
+ loadFromDatabase: async () => {},
26
+ async next(fn: (event: UpEvent) => Promise<void>) {
27
+ const event = this.queue.shift();
28
+ if (event) await fn(event);
29
+ },
30
+ } as unknown as UpQueue & { queue: UpEvent[] };
31
+
32
+ const downQueue = {
33
+ queue: [] as unknown[],
34
+ get size() {
35
+ return this.queue.length;
36
+ },
37
+ events: { subscribe: () => {} },
38
+ async next() {},
39
+ clear() {
40
+ this.queue = [];
41
+ },
42
+ } as unknown as DownQueue;
43
+
44
+ return { upQueue, downQueue };
45
+ }
46
+
47
+ const upEvent = (n: number) => ({ type: 'delete', mutation_id: n, record_id: n }) as unknown as UpEvent;
48
+
49
+ describe('SyncScheduler.pause', () => {
50
+ it('waits for the in-flight item (push + outbox delete) before resolving', async () => {
51
+ const { upQueue, downQueue } = makeQueues([upEvent(1), upEvent(2)]);
52
+ let release!: () => void;
53
+ const gate = new Promise<void>((r) => (release = r));
54
+ const processed: unknown[] = [];
55
+ const scheduler = new SyncScheduler(
56
+ upQueue,
57
+ downQueue,
58
+ async (event) => {
59
+ await gate; // simulate a slow remote push
60
+ processed.push(event);
61
+ },
62
+ async () => {},
63
+ silentLogger
64
+ );
65
+
66
+ const round = scheduler.syncUp();
67
+ const pauseDone = vi.fn();
68
+ const pause = scheduler.pause().then(pauseDone);
69
+
70
+ // The in-flight item hasn't finished — pause must not have resolved.
71
+ await Promise.resolve();
72
+ expect(pauseDone).not.toHaveBeenCalled();
73
+
74
+ release();
75
+ await pause;
76
+ await round;
77
+ // Exactly the in-flight item completed; the second stayed queued.
78
+ expect(processed).toHaveLength(1);
79
+ expect(upQueue.size).toBe(1);
80
+ });
81
+
82
+ it('refuses new rounds while paused and drains them on resume', async () => {
83
+ const { upQueue, downQueue } = makeQueues([upEvent(1)]);
84
+ const processed: unknown[] = [];
85
+ const scheduler = new SyncScheduler(
86
+ upQueue,
87
+ downQueue,
88
+ async (event) => {
89
+ processed.push(event);
90
+ },
91
+ async () => {},
92
+ silentLogger
93
+ );
94
+
95
+ await scheduler.pause();
96
+ await scheduler.syncUp();
97
+ expect(processed).toHaveLength(0);
98
+
99
+ scheduler.resume();
100
+ await vi.waitFor(() => expect(processed).toHaveLength(1));
101
+ expect(upQueue.size).toBe(0);
102
+ });
103
+
104
+ it('resolves immediately when nothing is in flight', async () => {
105
+ const { upQueue, downQueue } = makeQueues([]);
106
+ const scheduler = new SyncScheduler(upQueue, downQueue, async () => {}, async () => {}, silentLogger);
107
+ await expect(scheduler.pause()).resolves.toBeUndefined();
108
+ });
109
+ });