@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.160

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 (123) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1406 -364
  3. package/dist/index.js +7754 -1390
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +420 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +874 -0
  12. package/package.json +20 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/sp00ky-core/SKILL.md +258 -0
  15. package/skills/sp00ky-core/references/auth.md +98 -0
  16. package/skills/sp00ky-core/references/config.md +76 -0
  17. package/src/build-globals.d.ts +12 -0
  18. package/src/events/events.test.ts +2 -1
  19. package/src/events/index.ts +3 -0
  20. package/src/index.ts +16 -2
  21. package/src/modules/app-release/index.test.ts +125 -0
  22. package/src/modules/app-release/index.ts +201 -0
  23. package/src/modules/auth/events/index.ts +2 -1
  24. package/src/modules/auth/index.ts +59 -20
  25. package/src/modules/cache/index.ts +112 -32
  26. package/src/modules/cache/types.ts +2 -2
  27. package/src/modules/crdt/crdt-field.ts +294 -0
  28. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  29. package/src/modules/crdt/index.ts +361 -0
  30. package/src/modules/crdt/loro-loader.ts +25 -0
  31. package/src/modules/data/data.hydration.test.ts +142 -0
  32. package/src/modules/data/data.rebind.test.ts +147 -0
  33. package/src/modules/data/data.run.test.ts +113 -0
  34. package/src/modules/data/data.status.test.ts +249 -0
  35. package/src/modules/data/index.ts +1151 -129
  36. package/src/modules/data/mutation-id.test.ts +25 -0
  37. package/src/modules/data/mutation-id.ts +35 -0
  38. package/src/modules/data/window-query.test.ts +52 -0
  39. package/src/modules/data/window-query.ts +154 -0
  40. package/src/modules/devtools/index.ts +325 -37
  41. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  42. package/src/modules/devtools/storage-info.test.ts +79 -0
  43. package/src/modules/devtools/storage-info.ts +143 -0
  44. package/src/modules/devtools/versions.test.ts +74 -0
  45. package/src/modules/devtools/versions.ts +81 -0
  46. package/src/modules/feature-flag/index.test.ts +120 -0
  47. package/src/modules/feature-flag/index.ts +209 -0
  48. package/src/modules/ref-tables.test.ts +91 -0
  49. package/src/modules/ref-tables.ts +88 -0
  50. package/src/modules/sync/engine.ts +101 -37
  51. package/src/modules/sync/events/index.ts +9 -2
  52. package/src/modules/sync/queue/queue-down.ts +12 -5
  53. package/src/modules/sync/queue/queue-up.forwarded.test.ts +137 -0
  54. package/src/modules/sync/queue/queue-up.ts +198 -57
  55. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  56. package/src/modules/sync/scheduler.ts +77 -9
  57. package/src/modules/sync/sync.health.test.ts +149 -0
  58. package/src/modules/sync/sync.subquery.test.ts +82 -0
  59. package/src/modules/sync/sync.ts +1333 -92
  60. package/src/modules/sync/utils.test.ts +269 -2
  61. package/src/modules/sync/utils.ts +182 -17
  62. package/src/otel/index.ts +127 -0
  63. package/src/services/database/cache-engine.ts +160 -0
  64. package/src/services/database/database.ts +11 -11
  65. package/src/services/database/engine-factory.ts +33 -0
  66. package/src/services/database/events/index.ts +2 -1
  67. package/src/services/database/index.ts +6 -0
  68. package/src/services/database/local-migrator.ts +28 -27
  69. package/src/services/database/local.test.ts +64 -0
  70. package/src/services/database/local.ts +478 -67
  71. package/src/services/database/plan-render.test.ts +159 -0
  72. package/src/services/database/plan-render.ts +108 -0
  73. package/src/services/database/relation-resolver.test.ts +413 -0
  74. package/src/services/database/relation-resolver.ts +0 -0
  75. package/src/services/database/remote.ts +13 -13
  76. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  77. package/src/services/database/sqlite-cache-engine.ts +1154 -0
  78. package/src/services/database/sqlite-open.test.ts +150 -0
  79. package/src/services/database/sqlite-open.ts +164 -0
  80. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  81. package/src/services/database/sqlite-plan-sql.ts +106 -0
  82. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  83. package/src/services/database/sqlite-select.test.ts +246 -0
  84. package/src/services/database/sqlite-select.ts +113 -0
  85. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  86. package/src/services/database/sqlite-transport.ts +219 -0
  87. package/src/services/database/sqlite-worker.ts +437 -0
  88. package/src/services/database/surql-translate.ts +291 -0
  89. package/src/services/database/surreal-cache-engine.ts +141 -0
  90. package/src/services/logger/index.ts +6 -109
  91. package/src/services/persistence/localstorage.ts +2 -2
  92. package/src/services/persistence/resilient.ts +11 -4
  93. package/src/services/persistence/surrealdb.ts +10 -10
  94. package/src/services/stream-processor/index.ts +444 -52
  95. package/src/services/stream-processor/permissions.test.ts +47 -0
  96. package/src/services/stream-processor/permissions.ts +53 -0
  97. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  98. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  99. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  100. package/src/services/stream-processor/wasm-types.ts +23 -2
  101. package/src/services/tabs/broker-client.ts +283 -0
  102. package/src/services/tabs/broker.test.ts +278 -0
  103. package/src/services/tabs/coordinator.test.ts +192 -0
  104. package/src/services/tabs/coordinator.ts +567 -0
  105. package/src/services/tabs/fake-ports.fixture.ts +70 -0
  106. package/src/services/tabs/leader-locks.ts +75 -0
  107. package/src/services/tabs/protocol.ts +239 -0
  108. package/src/services/tabs/support.ts +36 -0
  109. package/src/services/tabs/tabs-broker-worker.ts +581 -0
  110. package/src/sp00ky.auth-order.test.ts +92 -0
  111. package/src/sp00ky.init-query.test.ts +183 -0
  112. package/src/sp00ky.ts +1225 -0
  113. package/src/types.ts +342 -15
  114. package/src/utils/error-classification.test.ts +44 -0
  115. package/src/utils/error-classification.ts +7 -0
  116. package/src/utils/index.ts +35 -13
  117. package/src/utils/parser.ts +3 -2
  118. package/src/utils/semver.test.ts +32 -0
  119. package/src/utils/semver.ts +30 -0
  120. package/src/utils/surql.ts +30 -18
  121. package/src/utils/withRetry.test.ts +1 -1
  122. package/tsdown.config.ts +86 -1
  123. package/src/spooky.ts +0 -395
@@ -1,10 +1,11 @@
1
- import { LocalDatabaseService } from '../../../services/database/index';
1
+ import type { LocalStore } from '../../../services/database/index';
2
+ import type {
3
+ SyncQueueEventSystem} from '../events/index';
2
4
  import {
3
5
  createSyncQueueEventSystem,
4
- SyncQueueEventSystem,
5
6
  SyncQueueEventTypes,
6
7
  } from '../events/index';
7
- import { Logger } from '../../../services/logger/index';
8
+ import type { Logger } from '../../../services/logger/index';
8
9
 
9
10
  export type RegisterEvent = {
10
11
  type: 'register';
@@ -46,7 +47,7 @@ export class DownQueue {
46
47
  }
47
48
 
48
49
  constructor(
49
- private local: LocalDatabaseService,
50
+ private local: LocalStore,
50
51
  logger: Logger
51
52
  ) {
52
53
  this._events = createSyncQueueEventSystem();
@@ -71,6 +72,12 @@ export class DownQueue {
71
72
  });
72
73
  }
73
74
 
75
+ /** Drop all queued events (bucket switch: old-bucket register/sync work is
76
+ * re-derived after the switch; replaying it would target dead query rows). */
77
+ clear(): void {
78
+ this.queue = [];
79
+ }
80
+
74
81
  async next(fn: (event: DownEvent) => Promise<void>): Promise<void> {
75
82
  const event = this.queue.shift();
76
83
  if (event) {
@@ -78,7 +85,7 @@ export class DownQueue {
78
85
  await fn(event);
79
86
  } catch (error) {
80
87
  this.logger.error(
81
- { error, event, Category: 'spooky-client::DownQueue::next' },
88
+ { error, event, Category: 'sp00ky-client::DownQueue::next' },
82
89
  'Failed to process query'
83
90
  );
84
91
  this.queue.unshift(event);
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { UpQueue } from './queue-up';
3
+ import { translateSurql } from '../../../services/database/surql-translate';
4
+ import { surql, classifySyncError } from '../../../utils/index';
5
+
6
+ function makeLogger(): any {
7
+ const noop = () => {};
8
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
9
+ l.child = () => l;
10
+ return l;
11
+ }
12
+
13
+ const ROW = {
14
+ id: '_00_pending_mutations:0001753875000000_0001_tabB',
15
+ mutationType: 'create',
16
+ recordId: 'game:g1',
17
+ data: { title: 'x' },
18
+ };
19
+
20
+ // Shared-tabs: followers commit outbox rows into the shared store and notify
21
+ // the leader by id. The notify may be REPLAYED (failover buffering), so the
22
+ // enqueue must be idempotent, and only the leader ever drains.
23
+ describe('UpQueue.enqueueFromDatabase', () => {
24
+ function makeQueue(rows: Record<string, unknown[]>) {
25
+ const query = vi.fn(async (_sql: string, vars?: Record<string, unknown>) => {
26
+ const ids = (vars?.mutation_ids as unknown[]) ?? [];
27
+ const id = String(ids[0] ?? '');
28
+ return [rows[id] ?? []];
29
+ });
30
+ const local: any = { query };
31
+ return { queue: new UpQueue(local, makeLogger()), query };
32
+ }
33
+
34
+ it('loads exactly the forwarded row and enqueues it once', async () => {
35
+ const { queue } = makeQueue({ [ROW.id]: [ROW] });
36
+ await queue.enqueueFromDatabase(ROW.id);
37
+ expect(queue.size).toBe(1);
38
+ });
39
+
40
+ it('is idempotent: a replayed notify does not double-enqueue', async () => {
41
+ const { queue, query } = makeQueue({ [ROW.id]: [ROW] });
42
+ await queue.enqueueFromDatabase(ROW.id);
43
+ await queue.enqueueFromDatabase(ROW.id);
44
+ expect(queue.size).toBe(1);
45
+ // The second call short-circuits before even touching the store.
46
+ expect(query).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it('a row that never committed is a silent no-op (the app promise already rejected)', async () => {
50
+ const { queue } = makeQueue({});
51
+ await queue.enqueueFromDatabase(ROW.id);
52
+ expect(queue.size).toBe(0);
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
+ });
137
+ });
@@ -1,13 +1,15 @@
1
- import { RecordId } from 'surrealdb';
2
- import { LocalDatabaseService } from '../../../services/database/index';
1
+ import type { RecordId } from 'surrealdb';
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: 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
+
62
119
  get size(): number {
63
120
  return this.queue.length;
64
121
  }
@@ -104,6 +161,20 @@ export class UpQueue {
104
161
  this.debouncedMutations.set(key, { timer, firstBeforeRecord });
105
162
  }
106
163
 
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
+
107
178
  async next(fn: (event: UpEvent) => Promise<void>, onRollback?: RollbackCallback): Promise<void> {
108
179
  const event = this.queue.shift();
109
180
  if (event) {
@@ -114,7 +185,7 @@ export class UpQueue {
114
185
 
115
186
  if (errorType === 'network') {
116
187
  this.logger.error(
117
- { error, event, Category: 'spooky-client::UpQueue::next' },
188
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
118
189
  'Network error processing mutation, re-queuing'
119
190
  );
120
191
  this.queue.unshift(event);
@@ -123,14 +194,14 @@ export class UpQueue {
123
194
 
124
195
  // Application error — rollback instead of re-queuing
125
196
  this.logger.error(
126
- { error, event, Category: 'spooky-client::UpQueue::next' },
197
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
127
198
  'Application error processing mutation, rolling back'
128
199
  );
129
200
  try {
130
201
  await this.removeEventFromDatabase(event.mutation_id);
131
202
  } catch (removeError) {
132
203
  this.logger.error(
133
- { error: removeError, event, Category: 'spooky-client::UpQueue::next' },
204
+ { error: removeError, event, Category: 'sp00ky-client::UpQueue::next' },
134
205
  'Failed to remove rolled-back mutation from database'
135
206
  );
136
207
  }
@@ -139,7 +210,7 @@ export class UpQueue {
139
210
  await onRollback(event, error instanceof Error ? error : new Error(String(error)));
140
211
  } catch (rollbackError) {
141
212
  this.logger.error(
142
- { error: rollbackError, event, Category: 'spooky-client::UpQueue::next' },
213
+ { error: rollbackError, event, Category: 'sp00ky-client::UpQueue::next' },
143
214
  'Rollback handler failed'
144
215
  );
145
216
  }
@@ -154,7 +225,7 @@ export class UpQueue {
154
225
  await this.removeEventFromDatabase(event.mutation_id);
155
226
  } catch (error) {
156
227
  this.logger.error(
157
- { error, event, Category: 'spooky-client::UpQueue::next' },
228
+ { error, event, Category: 'sp00ky-client::UpQueue::next' },
158
229
  'Failed to remove mutation from database after successful processing'
159
230
  );
160
231
  }
@@ -169,55 +240,125 @@ export class UpQueue {
169
240
  return this.local.query(`DELETE $mutation_id`, { mutation_id });
170
241
  }
171
242
 
243
+ /**
244
+ * Shared-tabs: a follower committed an outbox row into the shared store and
245
+ * notified the leader; enqueue exactly that row. Idempotent: a replayed
246
+ * notify (failover) or a row already queued is a no-op, and a row that never
247
+ * committed simply is not found (the follower's own write promise already
248
+ * rejected in that case).
249
+ */
250
+ async enqueueFromDatabase(mutationId: string): Promise<void> {
251
+ if (this.queue.some((e) => encodeUpEventId(e) === mutationId)) return;
252
+ try {
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)],
263
+ });
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');
272
+ } catch (error) {
273
+ this.logger.error(
274
+ { error, mutationId, Category: 'sp00ky-client::UpQueue::enqueueFromDatabase' },
275
+ 'Failed to load a forwarded mutation'
276
+ );
277
+ this.onDropped?.({
278
+ mutationId,
279
+ reason: error instanceof Error ? error.message : String(error),
280
+ });
281
+ }
282
+ }
283
+
172
284
  async loadFromDatabase() {
173
285
  try {
286
+ // ORDER BY id: mutation ids are zero-padded-timestamp-prefixed (see
287
+ // modules/data/mutation-id.ts), so lexicographic id order IS chronological
288
+ // order. (The previous created_at was never a defined field on this
289
+ // table, so the old ordering was undefined.)
174
290
  const [records] = await this.local.query<any>(
175
- `SELECT * FROM _spooky_pending_mutations ORDER BY created_at ASC`
291
+ `SELECT * FROM _00_pending_mutations ORDER BY id ASC`
176
292
  );
177
293
 
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);
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
+ }
216
308
  } catch (error) {
217
309
  this.logger.error(
218
- { error, Category: 'spooky-client::UpQueue::loadFromDatabase' },
310
+ { error, Category: 'sp00ky-client::UpQueue::loadFromDatabase' },
219
311
  'Failed to load pending mutations from database'
220
312
  );
221
313
  }
222
314
  }
223
315
  }
316
+
317
+ /** The stable string id of an event's outbox row. */
318
+ function encodeUpEventId(event: UpEvent): string {
319
+ return encodeRecordId(event.mutation_id);
320
+ }
321
+
322
+ /** Materialize one `_00_pending_mutations` row into an UpEvent. */
323
+ function rowToUpEvent(r: any, logger: Logger): UpEvent | null {
324
+ switch (r.mutationType) {
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;
332
+ return {
333
+ type: 'create',
334
+ mutation_id: parseRecordIdString(r.id),
335
+ record_id: parseRecordIdString(r.recordId),
336
+ data: r.data,
337
+ tableName: extractTablePart(r.recordId),
338
+ };
339
+ case 'update':
340
+ return {
341
+ type: 'update',
342
+ mutation_id: parseRecordIdString(r.id),
343
+ record_id: parseRecordIdString(r.recordId),
344
+ data: r.data,
345
+ beforeRecord: r.beforeRecord,
346
+ };
347
+ case 'delete':
348
+ return {
349
+ type: 'delete',
350
+ mutation_id: parseRecordIdString(r.id),
351
+ record_id: parseRecordIdString(r.recordId),
352
+ };
353
+ default:
354
+ logger.warn(
355
+ {
356
+ mutationType: r.mutationType,
357
+ record: r,
358
+ Category: 'sp00ky-client::UpQueue::rowToUpEvent',
359
+ },
360
+ 'Unknown mutation type'
361
+ );
362
+ return null;
363
+ }
364
+ }
@@ -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
+ });