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

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
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+
5
+ /**
6
+ * `getPendingRecordIds` used to run a full `SELECT ... FROM
7
+ * _00_pending_mutations` on EVERY materialization of EVERY query — a round trip
8
+ * down the local engine's single-flight op queue, paid tens of times per ingest
9
+ * against an outbox that is usually empty. It is now cached, invalidated when
10
+ * the outbox actually changes, with a short TTL as a backstop.
11
+ */
12
+
13
+ function makeLogger(): any {
14
+ const noop = () => {};
15
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
16
+ logger.child = () => logger;
17
+ return logger;
18
+ }
19
+
20
+ /** DataModule over a local store that counts outbox reads and can be told what
21
+ * to return. */
22
+ function makeModule(rows: { recordId: RecordId<string>; mutationType: string }[] = []) {
23
+ const state = { rows, reads: 0 };
24
+ const local = {
25
+ query: vi.fn(async () => {
26
+ state.reads++;
27
+ return [state.rows];
28
+ }),
29
+ };
30
+ const dm = new DataModule(
31
+ {} as any,
32
+ local as any,
33
+ { tables: [] } as any,
34
+ makeLogger(),
35
+ 100
36
+ );
37
+ return { dm, state };
38
+ }
39
+
40
+ const pending = (id: string, mutationType = 'update') => ({
41
+ recordId: new RecordId('game', id),
42
+ mutationType,
43
+ });
44
+
45
+ describe('DataModule pending-id cache', () => {
46
+ beforeEach(() => vi.useFakeTimers());
47
+ afterEach(() => vi.useRealTimers());
48
+
49
+ it('reads the outbox once for repeated calls', async () => {
50
+ const { dm, state } = makeModule([pending('a')]);
51
+ await dm.getPendingRecordIds();
52
+ await dm.getPendingRecordIds();
53
+ await dm.getPendingRecordIds();
54
+ expect(state.reads).toBe(1);
55
+ });
56
+
57
+ it('collapses a burst of CONCURRENT calls into one read', async () => {
58
+ // The real shape: one ingest fans out to many queries materializing at once.
59
+ const { dm, state } = makeModule([pending('a')]);
60
+ await Promise.all([
61
+ dm.getPendingRecordIds(),
62
+ dm.getPendingRecordIds(),
63
+ dm.getPendingRecordIds(),
64
+ dm.getPendingRecordIds(),
65
+ ]);
66
+ expect(state.reads).toBe(1);
67
+ });
68
+
69
+ it('hands out COPIES, so a caller mutating the sets cannot poison the cache', async () => {
70
+ // buildRenderIds merges the settled-write ids into what it gets back.
71
+ const { dm } = makeModule([pending('a')]);
72
+ const first = await dm.getPendingRecordIds();
73
+ first.writes.add('game:injected');
74
+ const second = await dm.getPendingRecordIds();
75
+ expect(second.writes.has('game:injected')).toBe(false);
76
+ expect([...second.writes]).toEqual(['game:a']);
77
+ });
78
+
79
+ it('re-reads after a mutation settles (its outbox row is gone)', async () => {
80
+ const { dm, state } = makeModule([pending('a')]);
81
+ await dm.getPendingRecordIds();
82
+ expect(state.reads).toBe(1);
83
+
84
+ state.rows = [];
85
+ dm.noteWriteSettled('game:a', 'update');
86
+ const after = await dm.getPendingRecordIds();
87
+
88
+ expect(state.reads).toBe(2);
89
+ expect(after.writes.size).toBe(0);
90
+ });
91
+
92
+ it('re-reads once the TTL backstop lapses', async () => {
93
+ // Covers any path that removes an outbox row without telling us: staleness
94
+ // is bounded to a tick rather than lasting the session.
95
+ const { dm, state } = makeModule([pending('a')]);
96
+ await dm.getPendingRecordIds();
97
+ expect(state.reads).toBe(1);
98
+
99
+ vi.setSystemTime(Date.now() + 1_000);
100
+ await dm.getPendingRecordIds();
101
+ expect(state.reads).toBe(2);
102
+ });
103
+
104
+ it('does NOT cache a failed read', async () => {
105
+ // Empty sets are this call's fallback, not a claim that the outbox is empty.
106
+ const { dm, state } = makeModule([pending('a')]);
107
+ (dm as any).local.query = vi.fn(async () => {
108
+ state.reads++;
109
+ throw new Error('engine down');
110
+ });
111
+ const failed = await dm.getPendingRecordIds();
112
+ expect(failed.writes.size).toBe(0);
113
+ expect(state.reads).toBe(1);
114
+
115
+ await dm.getPendingRecordIds();
116
+ expect(state.reads).toBe(2);
117
+ });
118
+
119
+ it('splits writes from deletes', async () => {
120
+ const { dm } = makeModule([pending('a'), pending('b', 'delete')]);
121
+ const { writes, deletes } = await dm.getPendingRecordIds();
122
+ expect([...writes]).toEqual(['game:a']);
123
+ expect([...deletes]).toEqual(['game:b']);
124
+ });
125
+ });
126
+
127
+ describe('DataModule pending-id cache: generations', () => {
128
+ beforeEach(() => vi.useFakeTimers());
129
+ afterEach(() => vi.useRealTimers());
130
+
131
+ // The outbox read is a round trip down the local op queue, so it can be
132
+ // queued BEFORE a create's outbox row commits and resolve AFTER
133
+ // `invalidatePendingIds()` ran for it. Cached as-is, that pre-create set
134
+ // hid the new row from every materialization that joined the read - the one
135
+ // stream update a CREATE gets, so the row was invisible until reload.
136
+ function makeDeferredModule() {
137
+ const pendingReads: Array<(rows: any[]) => void> = [];
138
+ const state = { reads: 0 };
139
+ const local = {
140
+ query: vi.fn(
141
+ () =>
142
+ new Promise<any[]>((resolve) => {
143
+ state.reads++;
144
+ pendingReads.push((rows) => resolve([rows]));
145
+ })
146
+ ),
147
+ };
148
+ const dm = new DataModule({} as any, local as any, { tables: [] } as any, makeLogger(), 100);
149
+ return { dm, state, pendingReads };
150
+ }
151
+ // Let the awaiting loop observe a resolved read and issue the next one.
152
+ const settle = async () => {
153
+ for (let i = 0; i < 8; i++) await Promise.resolve();
154
+ };
155
+
156
+ it('does not cache a read that was in flight when the outbox changed', async () => {
157
+ const { dm, state, pendingReads } = makeDeferredModule();
158
+ const first = dm.getPendingRecordIds();
159
+ expect(state.reads).toBe(1);
160
+ // The create commits and invalidates while the read is still out.
161
+ (dm as any).invalidatePendingIds();
162
+ // The stale read answers with the pre-create outbox...
163
+ pendingReads[0]!([]);
164
+ await settle();
165
+ // ...so the joiner re-read under the new generation and gets the fresh one.
166
+ expect(state.reads).toBe(2);
167
+ pendingReads[1]!([pending('new', 'create')]);
168
+ expect([...(await first).writes]).toEqual(['game:new']);
169
+ });
170
+
171
+ it('a stale in-flight result never repopulates the cache', async () => {
172
+ const { dm, state, pendingReads } = makeDeferredModule();
173
+ const first = dm.getPendingRecordIds();
174
+ (dm as any).invalidatePendingIds();
175
+ pendingReads[0]!([]);
176
+ await settle();
177
+ pendingReads[1]!([pending('new', 'create')]);
178
+ await first;
179
+ // A fresh call must be served from the cache the CURRENT generation's
180
+ // read wrote, i.e. see the new row without another read.
181
+ const again = await dm.getPendingRecordIds();
182
+ expect(state.reads).toBe(2);
183
+ expect([...again.writes]).toEqual(['game:new']);
184
+ });
185
+
186
+ it('bounds the re-read when the outbox changes on every tick', async () => {
187
+ const { dm, state, pendingReads } = makeDeferredModule();
188
+ const call = dm.getPendingRecordIds();
189
+ for (let i = 0; i < 5; i++) {
190
+ (dm as any).invalidatePendingIds();
191
+ pendingReads[i]?.([pending(`r${i}`)]);
192
+ await settle();
193
+ }
194
+ const result = await call;
195
+ // Three reads at most, and the caller still gets an answer.
196
+ expect(state.reads).toBeLessThanOrEqual(3);
197
+ expect(result.writes.size).toBe(1);
198
+ });
199
+ });
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * Tests for the local-bucket-switch surface of DataModule:
8
+ * - `quiesce()` disarms every debounce + TTL timer;
9
+ * - `rebindAfterBucketSwitch()` keeps query hashes (subscriptions stay
10
+ * attached), resets sync state, notifies subscribers with the emptied
11
+ * records so the previous user's rows leave the UI, re-registers the SSP
12
+ * view, and returns the hashes for remote re-registration;
13
+ * - stale-epoch stream updates are dropped instead of applied.
14
+ */
15
+
16
+ function makeLogger(): any {
17
+ const noop = () => {};
18
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
19
+ logger.child = () => logger;
20
+ return logger;
21
+ }
22
+
23
+ function makeQueryState(hash: string, records: Record<string, any>[]): QueryState {
24
+ return {
25
+ config: {
26
+ id: new RecordId('_00_query', hash),
27
+ surql: 'SELECT * FROM user',
28
+ params: {},
29
+ localArray: [['user:a', 1]],
30
+ remoteArray: [['user:a', 1]],
31
+ ttl: '10m',
32
+ lastActiveAt: new Date(),
33
+ tableName: 'user',
34
+ },
35
+ records,
36
+ hydrated: true,
37
+ ttlTimer: null,
38
+ ttlDurationMs: 600_000,
39
+ updateCount: 3,
40
+ lastUpdatedAt: null,
41
+ materializationSamples: [],
42
+ lastIngestLatencyMs: null,
43
+ errorCount: 0,
44
+ status: 'idle',
45
+ phaseSamples: {},
46
+ phaseLast: {},
47
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
48
+ };
49
+ }
50
+
51
+ function makeHarness(epoch = 0) {
52
+ const localQueries: Array<{ query: unknown; vars: unknown; opts: unknown }> = [];
53
+ const local: any = {
54
+ epoch,
55
+ query: vi.fn(async (query: unknown, vars?: unknown, opts?: unknown) => {
56
+ localQueries.push({ query, vars, opts });
57
+ return [[]];
58
+ }),
59
+ };
60
+ const cache: any = {
61
+ registerQuery: vi.fn(() => ({ localArray: [] })),
62
+ };
63
+ const dm = new DataModule(cache, local, { tables: [] } as any, makeLogger(), 100);
64
+ return { dm, local, cache, localQueries };
65
+ }
66
+
67
+ describe('DataModule.quiesce', () => {
68
+ it('clears debounce and TTL timers', () => {
69
+ vi.useFakeTimers();
70
+ try {
71
+ const { dm } = makeHarness();
72
+ const qs = makeQueryState('h1', []);
73
+ qs.ttlTimer = setTimeout(() => {}, 60_000);
74
+ (dm as any).activeQueries.set('h1', qs);
75
+ (dm as any).debounceTimers.set('h1', setTimeout(() => {}, 60_000));
76
+
77
+ dm.quiesce();
78
+
79
+ expect(qs.ttlTimer).toBeNull();
80
+ expect((dm as any).debounceTimers.size).toBe(0);
81
+ expect(vi.getTimerCount()).toBe(0);
82
+ } finally {
83
+ vi.useRealTimers();
84
+ }
85
+ });
86
+ });
87
+
88
+ describe('DataModule.rebindAfterBucketSwitch', () => {
89
+ let harness: ReturnType<typeof makeHarness>;
90
+
91
+ beforeEach(() => {
92
+ harness = makeHarness();
93
+ (harness.dm as any).activeQueries.set(
94
+ 'h1',
95
+ makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }])
96
+ );
97
+ });
98
+
99
+ it('keeps the hash, resets sync state, and notifies subscribers with empty records', async () => {
100
+ const { dm, cache } = harness;
101
+ const emissions: unknown[][] = [];
102
+ dm.subscribe('h1', (records) => emissions.push(records as unknown[]));
103
+
104
+ const hashes = await dm.rebindAfterBucketSwitch();
105
+
106
+ expect(hashes).toEqual(['h1']);
107
+ const qs = (dm as any).activeQueries.get('h1') as QueryState;
108
+ expect(qs.records).toEqual([]);
109
+ expect(qs.config.localArray).toEqual([]);
110
+ expect(qs.config.remoteArray).toEqual([]);
111
+ expect(qs.hydrated).toBe(false);
112
+ expect(qs.status).toBe('fetching');
113
+ // Subscribers saw the previous user's rows drop out.
114
+ expect(emissions).toEqual([[]]);
115
+ // The SSP view was re-registered (on the fresh, post-reset processor).
116
+ expect(cache.registerQuery).toHaveBeenCalledWith(
117
+ expect.objectContaining({ queryHash: 'h1', surql: 'SELECT * FROM user' })
118
+ );
119
+ // The TTL heartbeat is re-armed.
120
+ expect(qs.ttlTimer).not.toBeNull();
121
+ if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
122
+ });
123
+ });
124
+
125
+ describe('DataModule.rebindAfterBucketSwitch durable seed', () => {
126
+ it('seeds a confirmed-empty membership from the new bucket, and ignores an unconfirmed one', async () => {
127
+ const harness = makeHarness();
128
+ const { dm, local } = harness as any;
129
+ const state = makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }]);
130
+ state.config.membershipKey = 'stable-key';
131
+ (dm as any).activeQueries.set('h1', state);
132
+ local.getById = vi.fn(async () => ({ ids: [], confirmed: true }));
133
+
134
+ await dm.rebindAfterBucketSwitch();
135
+ let qs = (dm as any).activeQueries.get('h1') as QueryState;
136
+ expect(qs.config.membershipKnown).toBe(true);
137
+ expect(qs.config.remoteArray).toEqual([]);
138
+ if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
139
+
140
+ local.getById = vi.fn(async () => ({ ids: [] }));
141
+ await dm.rebindAfterBucketSwitch();
142
+ qs = (dm as any).activeQueries.get('h1') as QueryState;
143
+ expect(qs.config.membershipKnown).toBe(false);
144
+ if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
145
+ });
146
+ });
147
+
148
+ describe('stale-epoch stream updates', () => {
149
+ it('drops an update whose chain started before a bucket switch', async () => {
150
+ const { dm, local } = makeHarness();
151
+ const qs = makeQueryState('h1', [{ id: 'user:a' }]);
152
+ (dm as any).activeQueries.set('h1', qs);
153
+
154
+ // The materialize read happens, then the epoch moves (bucket switched).
155
+ local.query.mockImplementation(async () => {
156
+ local.epoch = 1;
157
+ return [[{ id: 'user:b', name: 'other user row' }]];
158
+ });
159
+
160
+ await (dm as any).processStreamUpdate({
161
+ queryHash: 'h1',
162
+ localArray: [['user:b', 1]],
163
+ op: 'CREATE',
164
+ });
165
+
166
+ // Neither the records nor the persisted arrays moved.
167
+ expect(qs.records).toEqual([{ id: 'user:a' }]);
168
+ expect(qs.config.localArray).toEqual([['user:a', 1]]);
169
+ });
170
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryPlan } from '@spooky-sync/query-builder';
5
+ import type { QueryState, RecordVersionArray } from '../../types';
6
+
7
+ /**
8
+ * A row this client wrote itself reaches server membership at the very
9
+ * version the local CREATE memoized, so the sync engine rightly fetches
10
+ * nothing when the membership lands - and then nothing re-materialized the
11
+ * query: `remoteArray` gained the id and no subscriber heard about it until a
12
+ * reload rebuilt the view from membership. `scheduleRematerialize` closes that
13
+ * gap through the same per-query debounce a real stream update uses.
14
+ */
15
+
16
+ const noop = () => {};
17
+ function makeLogger(): any {
18
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
19
+ logger.child = () => logger;
20
+ return logger;
21
+ }
22
+
23
+ const schema = { tables: [{ name: 'message', columns: {} }] } as any;
24
+ const plan: QueryPlan = { table: 'message', where: [['conversation', '=', 'conversation:c1']] } as any;
25
+
26
+ function makeQueryState(hash: string, remoteArray: RecordVersionArray, localArray: RecordVersionArray): QueryState {
27
+ return {
28
+ config: {
29
+ id: new RecordId('_00_query', hash),
30
+ surql: 'SELECT * FROM message WHERE conversation = $conversation;',
31
+ plan,
32
+ params: { conversation: 'conversation:c1' },
33
+ localArray,
34
+ remoteArray,
35
+ membershipKnown: true,
36
+ ttl: '10m',
37
+ lastActiveAt: new Date(),
38
+ tableName: 'message',
39
+ },
40
+ records: [],
41
+ ttlTimer: null,
42
+ ttlDurationMs: 0,
43
+ updateCount: 0,
44
+ lastUpdatedAt: null,
45
+ materializationSamples: [],
46
+ lastIngestLatencyMs: null,
47
+ errorCount: 0,
48
+ status: 'idle',
49
+ phaseSamples: {},
50
+ phaseLast: {},
51
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
52
+ } as QueryState;
53
+ }
54
+
55
+ function setup(remoteArray: RecordVersionArray, localArray: RecordVersionArray) {
56
+ const body = { id: new RecordId('message', 'm1'), text: 'hi' };
57
+ const local: any = {
58
+ epoch: 1,
59
+ select: vi.fn(async (p: QueryPlan) => {
60
+ const ids = ((p as any).ids as RecordId[] | undefined) ?? [];
61
+ return ids.some((id) => id.toString() === 'message:m1') ? [body] : [];
62
+ }),
63
+ query: vi.fn(async (sql: string) => {
64
+ if (sql.includes('_00_pending_mutations')) return [[]];
65
+ return [[]];
66
+ }),
67
+ };
68
+ const dm = new DataModule({} as any, local, schema, makeLogger(), 10);
69
+ const hash = 'q1';
70
+ (dm as any).activeQueries.set(hash, makeQueryState(hash, remoteArray, localArray));
71
+ const subscriber = vi.fn();
72
+ (dm as any).subscriptions.set(hash, new Set([subscriber]));
73
+ return { dm, local, hash, subscriber };
74
+ }
75
+
76
+ describe('DataModule.scheduleRematerialize', () => {
77
+ afterEach(() => vi.useRealTimers());
78
+
79
+ it('notifies subscribers with the row once membership holds it', async () => {
80
+ vi.useFakeTimers();
81
+ const { dm, local, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
82
+ dm.scheduleRematerialize(hash);
83
+ await vi.advanceTimersByTimeAsync(20);
84
+ expect(subscriber).toHaveBeenCalledTimes(1);
85
+ expect(subscriber.mock.calls[0]![0].map((r: any) => r.id.toString())).toEqual(['message:m1']);
86
+ // A synthetic update describes no ingest: nothing is persisted to _00_query.
87
+ expect(local.query.mock.calls.some(([sql]: [string]) => sql.includes('localArray'))).toBe(false);
88
+ });
89
+
90
+ it('is a no-op behind a pending real stream update', async () => {
91
+ vi.useFakeTimers();
92
+ const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
93
+ await dm.onStreamUpdate({ queryHash: hash, localArray: [['message:m1', 1]], op: 'CREATE' });
94
+ dm.scheduleRematerialize(hash);
95
+ await vi.advanceTimersByTimeAsync(20);
96
+ // Exactly one notify: the real update's, not two.
97
+ expect(subscriber).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('does not notify when the records are unchanged', async () => {
101
+ vi.useFakeTimers();
102
+ const { dm, hash, subscriber } = setup([['message:m1', 1]], [['message:m1', 1]]);
103
+ dm.scheduleRematerialize(hash);
104
+ await vi.advanceTimersByTimeAsync(20);
105
+ dm.scheduleRematerialize(hash);
106
+ await vi.advanceTimersByTimeAsync(20);
107
+ expect(subscriber).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('ignores an unknown query', () => {
111
+ const { dm } = setup([], []);
112
+ expect(() => dm.scheduleRematerialize('nope')).not.toThrow();
113
+ });
114
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { DataModule } from './index';
3
+
4
+ /**
5
+ * Tests for `DataModule.run`, the one-shot outbox API.
6
+ *
7
+ * Recurring jobs used to live here too (`runRecurring` / `pokeRecurring` /
8
+ * `cancelRecurring`, which wrote a single durable row the runner re-armed
9
+ * forever). They are gone: schedules are now declared server-side under
10
+ * `schedules:` in sp00ky.yml, and the scheduler creates a fresh job row per
11
+ * cycle — so every row this API writes is exactly one execution.
12
+ *
13
+ * The create/update pipeline is spied, so these assert the orchestration
14
+ * (table resolution, argument validation, field building) without a real engine.
15
+ */
16
+
17
+ function makeLogger(): any {
18
+ const noop = () => {};
19
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
20
+ logger.child = () => logger;
21
+ return logger;
22
+ }
23
+
24
+ const schema = {
25
+ tables: [{ name: 'job', columns: {} }],
26
+ backends: {
27
+ gamesync: {
28
+ outboxTable: 'job',
29
+ routes: {
30
+ '/syncGames': {
31
+ args: { connection: { optional: false }, since: { optional: true } },
32
+ },
33
+ },
34
+ },
35
+ noOutbox: {
36
+ routes: { '/whatever': { args: {} } },
37
+ },
38
+ },
39
+ };
40
+
41
+ const CONN = 'connection:CONN_abc';
42
+
43
+ function makeDm() {
44
+ const local = { query: vi.fn().mockResolvedValue([]) };
45
+ const dm = new DataModule({} as any, local as any, schema as any, makeLogger(), 100);
46
+ const create = vi.spyOn(dm, 'create').mockResolvedValue(undefined as any);
47
+ return { dm, local, create };
48
+ }
49
+
50
+ describe('DataModule.run', () => {
51
+ it('creates the one-shot job with status pending so the optimistic row reads in-flight', async () => {
52
+ const { dm, create } = makeDm();
53
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, {
54
+ assignedTo: CONN,
55
+ });
56
+
57
+ expect(create).toHaveBeenCalledTimes(1);
58
+ const [id, record] = create.mock.calls[0] as [string, any];
59
+ expect(id.startsWith('job:')).toBe(true);
60
+ // The schema's DEFAULT ALWAYS "pending" only runs server-side; without the
61
+ // explicit field the local optimistic row has status undefined and
62
+ // in-flight indicators miss it until the first server echo.
63
+ expect(record.status).toBe('pending');
64
+ });
65
+
66
+ it('gives each call its own row', async () => {
67
+ const { dm, create } = makeDm();
68
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
69
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
70
+
71
+ const [firstId] = create.mock.calls[0] as [string, any];
72
+ const [secondId] = create.mock.calls[1] as [string, any];
73
+ expect(firstId).not.toBe(secondId);
74
+ });
75
+
76
+ it('carries the retry, timeout and delay options onto the row', async () => {
77
+ const { dm, create } = makeDm();
78
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any, {
79
+ assignedTo: CONN,
80
+ max_retries: 5,
81
+ retry_strategy: 'exponential',
82
+ timeout: 30,
83
+ delay: 60_000,
84
+ });
85
+
86
+ const [, record] = create.mock.calls[0] as [string, any];
87
+ expect(record.max_retries).toBe(5);
88
+ expect(record.retry_strategy).toBe('exponential');
89
+ expect(record.timeout).toBe(30);
90
+ expect(record.delay).toBe(60_000);
91
+ });
92
+
93
+ it('serializes the payload and rejects a missing required argument', async () => {
94
+ const { dm, create } = makeDm();
95
+ await dm.run('gamesync' as any, '/syncGames' as any, { connection: CONN } as any);
96
+ const [, record] = create.mock.calls[0] as [string, any];
97
+ expect(JSON.parse(record.payload)).toEqual({ connection: CONN });
98
+
99
+ await expect(
100
+ dm.run('gamesync' as any, '/syncGames' as any, {} as any)
101
+ ).rejects.toThrow(/connection/);
102
+ });
103
+
104
+ it('rejects an unknown route and a backend with no outbox table', async () => {
105
+ const { dm } = makeDm();
106
+ await expect(
107
+ dm.run('gamesync' as any, '/nope' as any, {} as any)
108
+ ).rejects.toThrow(/not found/);
109
+ await expect(
110
+ dm.run('noOutbox' as any, '/whatever' as any, {} as any)
111
+ ).rejects.toThrow(/Outbox table/);
112
+ });
113
+ });