@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,149 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Sp00kySync } from './sync';
3
+
4
+ // The idle list-ref poll is the only health signal that runs on a quiet page.
5
+ // These tests exercise `pollListRefForActiveQueries` -> `recordSyncOutcome` in
6
+ // isolation: a run of network-failed cycles degrades, and a single clean cycle
7
+ // recovers WITHOUT any mutation (the reported bug). Construction is cheap —
8
+ // Sp00kySync's constructor only stores refs and calls logger.child; no timers or
9
+ // I/O start until init(), which we never call.
10
+
11
+ const CONNECTION_UNAVAILABLE =
12
+ 'You must be connected to a SurrealDB instance before performing this operation';
13
+
14
+ function makeSync(hashes: string[]) {
15
+ const logger: any = {
16
+ child: () => logger,
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ trace: () => {},
22
+ };
23
+ const remote: any = { query: vi.fn().mockResolvedValue([true]) };
24
+ const dataModule: any = { getActiveQueryHashes: () => hashes };
25
+
26
+ const sync = new Sp00kySync(
27
+ {} as any,
28
+ remote,
29
+ {} as any,
30
+ dataModule,
31
+ {} as any,
32
+ logger,
33
+ { degradeAfterConsecutiveFailures: 3 }
34
+ );
35
+
36
+ // `refetchListRefForQuery` is what the poll calls per active hash; stub it so
37
+ // the test controls per-cycle reachability. `poll` invokes the private method.
38
+ const refetch = vi.fn();
39
+ (sync as any).refetchListRefForQuery = refetch;
40
+ const poll = () => (sync as any).pollListRefForActiveQueries() as Promise<boolean>;
41
+
42
+ return { sync, remote, refetch, poll };
43
+ }
44
+
45
+ describe('sync health via idle poll', () => {
46
+ beforeEach(() => vi.clearAllMocks());
47
+
48
+ it('degrades after N consecutive network-failed poll cycles', async () => {
49
+ const { sync, refetch, poll } = makeSync(['h1']);
50
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
51
+
52
+ await poll();
53
+ expect(sync.syncHealth.status).toBe('healthy');
54
+ await poll();
55
+ expect(sync.syncHealth.status).toBe('healthy');
56
+ await poll();
57
+ expect(sync.syncHealth.status).toBe('degraded');
58
+ expect(sync.syncHealth.kind).toBe('network');
59
+ });
60
+
61
+ it('recovers on the next clean poll cycle — no mutation needed', async () => {
62
+ const { sync, refetch, poll } = makeSync(['h1']);
63
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
64
+ await poll();
65
+ await poll();
66
+ await poll();
67
+ expect(sync.syncHealth.status).toBe('degraded');
68
+
69
+ // Connectivity returns; a plain idle poll (no user action) clears it.
70
+ refetch.mockResolvedValue(true);
71
+ await poll();
72
+ expect(sync.syncHealth.status).toBe('healthy');
73
+ });
74
+
75
+ it('does not degrade on application errors (server was reached)', async () => {
76
+ const { sync, refetch, poll } = makeSync(['h1']);
77
+ refetch.mockRejectedValue(new Error('Permission denied'));
78
+ await poll();
79
+ await poll();
80
+ await poll();
81
+ await poll();
82
+ expect(sync.syncHealth.status).toBe('healthy');
83
+ });
84
+
85
+ it('counts a mixed cycle (one reachable hash) as reached', async () => {
86
+ const { sync, refetch, poll } = makeSync(['h1', 'h2']);
87
+ // First degrade via all-network cycles.
88
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
89
+ await poll();
90
+ await poll();
91
+ await poll();
92
+ expect(sync.syncHealth.status).toBe('degraded');
93
+
94
+ // Now one hash succeeds, the other still network-fails → reached → healthy.
95
+ refetch.mockReset();
96
+ refetch
97
+ .mockResolvedValueOnce(true)
98
+ .mockRejectedValueOnce(new Error(CONNECTION_UNAVAILABLE));
99
+ await poll();
100
+ expect(sync.syncHealth.status).toBe('healthy');
101
+ });
102
+
103
+ it('probes RETURN true when there are no active queries', async () => {
104
+ const { sync, remote, poll } = makeSync([]);
105
+ remote.query.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
106
+ await poll();
107
+ await poll();
108
+ await poll();
109
+ expect(remote.query).toHaveBeenCalledWith('RETURN true');
110
+ expect(sync.syncHealth.status).toBe('degraded');
111
+
112
+ remote.query.mockResolvedValue([true]);
113
+ await poll();
114
+ expect(sync.syncHealth.status).toBe('healthy');
115
+ });
116
+
117
+ it('leaves everConnected false through a cold-start failure run', async () => {
118
+ const { sync, refetch, poll } = makeSync(['h1']);
119
+ expect(sync.syncHealth.everConnected).toBe(false);
120
+
121
+ // Server never reached: 3 failed cycles degrade, but this is the initial
122
+ // "connecting" phase, not a lost connection — everConnected stays false.
123
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
124
+ await poll();
125
+ await poll();
126
+ await poll();
127
+ expect(sync.syncHealth.status).toBe('degraded');
128
+ expect(sync.syncHealth.everConnected).toBe(false);
129
+ });
130
+
131
+ it('latches everConnected on the first success and keeps it through a later degrade', async () => {
132
+ const { sync, refetch, poll } = makeSync(['h1']);
133
+
134
+ // First successful round reaches the server: connecting phase is over.
135
+ refetch.mockResolvedValue(true);
136
+ await poll();
137
+ expect(sync.syncHealth.status).toBe('healthy');
138
+ expect(sync.syncHealth.everConnected).toBe(true);
139
+
140
+ // Connection later drops: degraded now reflects a REAL lost connection.
141
+ refetch.mockReset();
142
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
143
+ await poll();
144
+ await poll();
145
+ await poll();
146
+ expect(sync.syncHealth.status).toBe('degraded');
147
+ expect(sync.syncHealth.everConnected).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+
5
+ /**
6
+ * `fn::query::heartbeat` is an `UPDATE $id SET ...`. Against a record that no
7
+ * longer exists it matches nothing and returns an empty array — it does NOT
8
+ * recreate the row. Verified against the deployed function:
9
+ *
10
+ * RETURN fn::query::heartbeat(_00_query:definitely_not_a_real_row_xyz);
11
+ * -- (0 rows)
12
+ *
13
+ * So an unchecked heartbeat cannot tell "refreshed" from "the row I am
14
+ * refreshing is gone", and a client whose row was reclaimed by the TTL sweep
15
+ * beats against nothing forever: no membership, no edges, no re-registration.
16
+ * The page then renders as though the data had been deleted — reported as
17
+ * "Game not found" on a game that was open and working.
18
+ *
19
+ * This is reachable in ordinary use: the sweep expires on `lastActiveAt + ttl`
20
+ * while the heartbeat runs on a timer browsers throttle hard in background
21
+ * tabs, so a second window left idle past its TTL is the normal way in.
22
+ */
23
+
24
+ function makeSync(heartbeatResult: unknown) {
25
+ const logger: any = {
26
+ child: () => logger,
27
+ debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
28
+ };
29
+ const remote: any = { query: vi.fn().mockResolvedValue(heartbeatResult) };
30
+ const queryState: any = { config: { id: new RecordId('_00_query', 'h1') } };
31
+ const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
32
+
33
+ const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
34
+ const enqueueDownEvent = vi.fn();
35
+ (sync as any).enqueueDownEvent = enqueueDownEvent;
36
+
37
+ return { sync, remote, dataModule, enqueueDownEvent };
38
+ }
39
+
40
+ describe('heartbeatQuery — noticing a reclaimed row', () => {
41
+ beforeEach(() => vi.clearAllMocks());
42
+
43
+ it('re-registers when the row it beat against is gone', async () => {
44
+ // `UPDATE` on a deleted record: one statement, zero updated records.
45
+ const { sync, enqueueDownEvent } = makeSync([[]]);
46
+
47
+ await sync.heartbeatQuery('h1');
48
+
49
+ expect(enqueueDownEvent).toHaveBeenCalledWith({
50
+ type: 'register',
51
+ payload: { hash: 'h1' },
52
+ });
53
+ });
54
+
55
+ it('does nothing extra on a healthy heartbeat', async () => {
56
+ const { sync, enqueueDownEvent } = makeSync([[{ id: 'x', lastActiveAt: 'now' }]]);
57
+
58
+ await sync.heartbeatQuery('h1');
59
+
60
+ expect(enqueueDownEvent).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('does not re-register on an unrecognised result shape', async () => {
64
+ // Only an explicitly EMPTY update result means "the row is gone". Anything
65
+ // else — a driver returning null, a shape change — must not be read as
66
+ // deletion, or every heartbeat would re-register the whole working set.
67
+ for (const shape of [null, undefined, [], [null], ['unexpected']]) {
68
+ const { sync, enqueueDownEvent } = makeSync(shape);
69
+ await sync.heartbeatQuery('h1');
70
+ expect(enqueueDownEvent, `shape ${JSON.stringify(shape)}`).not.toHaveBeenCalled();
71
+ }
72
+ });
73
+
74
+ it('still throws for a query that is no longer registered locally', async () => {
75
+ const { sync, dataModule } = makeSync([[]]);
76
+ dataModule.getQueryByHash.mockReturnValue(undefined);
77
+
78
+ await expect(sync.heartbeatQuery('gone')).rejects.toThrow();
79
+ });
80
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+
5
+ // A LIVE `_00_list_ref` DELETE is how one window learns another deleted a record.
6
+ //
7
+ // `remoteArray` — the authoritative membership rows are now rendered FROM — used
8
+ // to be written only by registration and the poll, so a LIVE removal left the
9
+ // departed id in the list (in memory AND persisted) until the next poll tick: up
10
+ // to 5s of showing a deleted row, and on a page that then went offline, forever.
11
+ //
12
+ // A removal-only diff also gets no re-render for free: `runSyncForQuery` leaves
13
+ // `fetching` false, and a removal needs no record fetch to trigger a stream
14
+ // update, so the notify has to be forced.
15
+
16
+ function makeSync(opts: { membershipKnown?: boolean } = {}) {
17
+ const logger: any = {
18
+ child: () => logger,
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn: () => {},
22
+ error: () => {},
23
+ trace: () => {},
24
+ };
25
+ const queryId = new RecordId('_00_query', 'h1');
26
+ const queryState: any = {
27
+ config: {
28
+ id: queryId,
29
+ localArray: [
30
+ ['thread:a', 1],
31
+ ['thread:b', 1],
32
+ ],
33
+ remoteArray: [
34
+ ['thread:a', 1],
35
+ ['thread:b', 1],
36
+ ],
37
+ membershipKnown: opts.membershipKnown ?? true,
38
+ membershipKey: 'stable-key',
39
+ },
40
+ };
41
+
42
+ const updateQueryRemoteArray = vi.fn(async (_h: string, next: any) => {
43
+ queryState.config.remoteArray = next;
44
+ });
45
+ const notifyQuerySynced = vi.fn().mockResolvedValue(undefined);
46
+ const dataModule: any = {
47
+ scheduleRematerialize: vi.fn(),
48
+ getQueryById: vi.fn().mockReturnValue(queryState),
49
+ getQueryByHash: vi.fn().mockReturnValue(queryState),
50
+ updateQueryRemoteArray,
51
+ notifyQuerySynced,
52
+ getActiveQueryHashes: () => ['h1'],
53
+ getPendingRecordIds: async () => ({ writes: new Set(), deletes: new Set() }),
54
+ };
55
+
56
+ const sync = new Sp00kySync(
57
+ {} as any,
58
+ { query: vi.fn() } as any,
59
+ {} as any,
60
+ dataModule,
61
+ {} as any,
62
+ logger
63
+ );
64
+ // `runSyncForQuery` drives the engine + scheduler; both are out of scope here.
65
+ (sync as any).runSyncForQuery = vi.fn().mockResolvedValue(undefined);
66
+
67
+ const live = (action: 'CREATE' | 'UPDATE' | 'DELETE', id: string, version: number) =>
68
+ (sync as any).handleRemoteListRefChange(
69
+ action,
70
+ queryId,
71
+ new RecordId('thread', id),
72
+ version
73
+ ) as Promise<void>;
74
+
75
+ return { sync, queryState, updateQueryRemoteArray, notifyQuerySynced, live };
76
+ }
77
+
78
+ describe('LIVE list_ref removal → membership', () => {
79
+ beforeEach(() => vi.clearAllMocks());
80
+
81
+ it('drops the removed id from remoteArray immediately', async () => {
82
+ const { queryState, updateQueryRemoteArray, live } = makeSync();
83
+
84
+ await live('DELETE', 'b', 2);
85
+
86
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [['thread:a', 1]]);
87
+ expect(queryState.config.remoteArray).toEqual([['thread:a', 1]]);
88
+ });
89
+
90
+ it('forces a re-render for a removal-only diff', async () => {
91
+ const { notifyQuerySynced, live } = makeSync();
92
+ await live('DELETE', 'b', 2);
93
+ expect(notifyQuerySynced).toHaveBeenCalledWith('h1');
94
+ });
95
+
96
+ it('does not force a re-render when rows were added (the stream update covers it)', async () => {
97
+ const { notifyQuerySynced, live } = makeSync();
98
+ await live('CREATE', 'c', 1);
99
+ expect(notifyQuerySynced).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('adds a newly-arrived id to membership', async () => {
103
+ const { updateQueryRemoteArray, live } = makeSync();
104
+
105
+ await live('CREATE', 'c', 1);
106
+
107
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
108
+ ['thread:a', 1],
109
+ ['thread:b', 1],
110
+ ['thread:c', 1],
111
+ ]);
112
+ });
113
+
114
+ // Every tab that ingested a write optimistically (its own, or one relayed
115
+ // from another tab) already holds the row at the server's version, so the
116
+ // fetch diff is empty. Membership still has to be recorded, or the row lives
117
+ // on the settled-write grace alone until the poll catches it.
118
+ it('adds membership for a row the circuit already holds at the server version', async () => {
119
+ const { queryState, updateQueryRemoteArray, sync, live } = makeSync();
120
+ queryState.config.localArray = [
121
+ ['thread:a', 1],
122
+ ['thread:b', 1],
123
+ ['thread:c', 1],
124
+ ];
125
+
126
+ await live('CREATE', 'c', 1);
127
+
128
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
129
+ ['thread:a', 1],
130
+ ['thread:b', 1],
131
+ ['thread:c', 1],
132
+ ]);
133
+ // …without a refetch: the diff handed to the sync is empty.
134
+ expect((sync as any).runSyncForQuery).toHaveBeenCalledWith('h1', {
135
+ added: [],
136
+ updated: [],
137
+ removed: [],
138
+ });
139
+ });
140
+
141
+ it('records a bumped version on UPDATE without rewriting an unchanged list', async () => {
142
+ const { updateQueryRemoteArray, live } = makeSync();
143
+
144
+ await live('UPDATE', 'b', 1);
145
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
146
+
147
+ await live('UPDATE', 'b', 2);
148
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
149
+ ['thread:a', 1],
150
+ ['thread:b', 2],
151
+ ]);
152
+ });
153
+
154
+ it('leaves membership alone while it is still unknown', async () => {
155
+ // Nothing authoritative has arrived yet, so there is no list to amend —
156
+ // registration will supply the whole thing shortly.
157
+ const { updateQueryRemoteArray, live } = makeSync({ membershipKnown: false });
158
+ await live('DELETE', 'b', 2);
159
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('is a no-op for an unknown query', async () => {
163
+ const { sync, updateQueryRemoteArray } = makeSync();
164
+ (sync as any).dataModule.getQueryById = vi.fn().mockReturnValue(undefined);
165
+
166
+ await (sync as any).handleRemoteListRefChange(
167
+ 'DELETE',
168
+ new RecordId('_00_query', 'other'),
169
+ new RecordId('thread', 'b'),
170
+ 2
171
+ );
172
+
173
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
174
+ });
175
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Sp00kySync } from './sync';
3
+
4
+ // The reconnect handler re-registers active queries and re-issues the ref LIVE.
5
+ // It must fire on BOTH drop paths, and the distinction is the whole bug:
6
+ //
7
+ // - recovered drop: error -> reconnecting -> connected (no `disconnected`!)
8
+ // - gave-up drop: error -> disconnected -> (supervisor) -> connected
9
+ //
10
+ // Watching only `disconnected` therefore misses every *successful* reconnect —
11
+ // the common case — and leaves the dead server-side LIVE in place with the
12
+ // list_ref poll silently the only sync path.
13
+
14
+ /** Minimal stand-in for the SDK's event publisher. */
15
+ function makeClient() {
16
+ const listeners = new Map<string, Array<(...a: any[]) => void>>();
17
+ return {
18
+ subscribe(event: string, cb: (...a: any[]) => void) {
19
+ const arr = listeners.get(event) ?? [];
20
+ arr.push(cb);
21
+ listeners.set(event, arr);
22
+ return () => {
23
+ /* noop */
24
+ };
25
+ },
26
+ emit(event: string, ...args: any[]) {
27
+ for (const cb of listeners.get(event) ?? []) cb(...args);
28
+ },
29
+ liveOf: async () => ({ subscribe: () => () => {} }),
30
+ };
31
+ }
32
+
33
+ function makeSync(hashes: string[], userId: string | null = 'user:alice') {
34
+ const logger: any = {
35
+ child: () => logger,
36
+ debug: () => {},
37
+ info: () => {},
38
+ warn: () => {},
39
+ error: () => {},
40
+ trace: () => {},
41
+ };
42
+ const client = makeClient();
43
+ const status = { value: 'connected' as string };
44
+ const remote: any = {
45
+ query: vi.fn().mockResolvedValue(['live-uuid']),
46
+ getClient: () => client,
47
+ getStatus: () => status.value,
48
+ };
49
+ const dataModule: any = {
50
+ getActiveQueryHashes: () => hashes,
51
+ getCurrentUserId: () => userId,
52
+ };
53
+
54
+ const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
55
+
56
+ // `subscribeToReconnect` is normally wired from init(), which would also start
57
+ // timers and load the outbox. Call it directly instead.
58
+ (sync as any).currentUserId = userId;
59
+ (sync as any).subscribeToReconnect();
60
+
61
+ const enqueued: any[] = [];
62
+ (sync as any).scheduler = { enqueueDownEvent: (e: any) => enqueued.push(e) };
63
+
64
+ return { sync, client, remote, enqueued, status };
65
+ }
66
+
67
+ /** Let the `connected` handler's un-awaited `restartRefLiveQuery()` chain run. */
68
+ const flush = () => new Promise((r) => setTimeout(r, 0));
69
+
70
+ describe('sync reconnect re-subscription', () => {
71
+ beforeEach(() => vi.clearAllMocks());
72
+
73
+ it('re-registers queries and restarts LIVE on a recovered drop (reconnecting -> connected)', async () => {
74
+ const { client, remote, enqueued } = makeSync(['h1', 'h2']);
75
+
76
+ // The SDK's own reconnect: no `disconnected` is ever published.
77
+ client.emit('reconnecting');
78
+ client.emit('connected');
79
+ await flush();
80
+
81
+ expect(enqueued).toEqual([
82
+ { type: 'register', payload: { hash: 'h1' } },
83
+ { type: 'register', payload: { hash: 'h2' } },
84
+ ]);
85
+ const sqls = (remote.query as any).mock.calls.map((c: any[]) => c[0]);
86
+ expect(sqls.some((s: string) => s.startsWith('LIVE SELECT'))).toBe(true);
87
+ });
88
+
89
+ it('re-registers queries and restarts LIVE after the SDK gives up (disconnected -> connected)', async () => {
90
+ const { client, remote, enqueued } = makeSync(['h1']);
91
+
92
+ client.emit('disconnected');
93
+ client.emit('connected');
94
+ await flush();
95
+
96
+ expect(enqueued).toEqual([{ type: 'register', payload: { hash: 'h1' } }]);
97
+ const sqls = (remote.query as any).mock.calls.map((c: any[]) => c[0]);
98
+ expect(sqls.some((s: string) => s.startsWith('LIVE SELECT'))).toBe(true);
99
+ });
100
+
101
+ it('does nothing on the initial connect', async () => {
102
+ const { client, remote, enqueued } = makeSync(['h1']);
103
+
104
+ client.emit('connected');
105
+ await flush();
106
+
107
+ expect(enqueued).toEqual([]);
108
+ expect(remote.query).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('only reacts once per drop', async () => {
112
+ const { client, enqueued } = makeSync(['h1']);
113
+
114
+ client.emit('reconnecting');
115
+ client.emit('connected');
116
+ await flush();
117
+ // A spurious second `connected` (e.g. a re-published event) must not
118
+ // trigger a second refetch storm.
119
+ client.emit('connected');
120
+ await flush();
121
+
122
+ expect(enqueued).toHaveLength(1);
123
+ });
124
+
125
+ it("does not KILL the dead session's LIVE uuid on reconnect", async () => {
126
+ const { sync, client, remote } = makeSync(['h1']);
127
+ // A LIVE was running before the drop. Its uuid belongs to the old
128
+ // WebSocket session, so KILLing it on the new socket is pointless work
129
+ // queued ahead of the restart that actually matters.
130
+ const unsub = vi.fn();
131
+ (sync as any).currentLiveQueryUuid = 'stale-uuid';
132
+ (sync as any).liveQueryUnsubscribe = unsub;
133
+
134
+ client.emit('reconnecting');
135
+ expect(unsub).toHaveBeenCalled();
136
+ expect((sync as any).currentLiveQueryUuid).toBeNull();
137
+
138
+ client.emit('connected');
139
+ await flush();
140
+
141
+ const sqls = (remote.query as any).mock.calls.map((c: any[]) => c[0]);
142
+ expect(sqls.some((q: string) => q.includes('KILL'))).toBe(false);
143
+ expect(sqls.some((q: string) => q.startsWith('LIVE SELECT'))).toBe(true);
144
+ });
145
+ });
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+ import { buildSubqueryListRefSelect } from './utils';
5
+ import { encodeRecordId } from '../../utils/index';
6
+
7
+ // Guards the CLIENT half of `.related()` (author/comments) delivery: given
8
+ // subquery-child edges on the server (`_00_list_ref…` rows with
9
+ // `parent IS NOT NONE`), `syncSubqueryChildren` MUST pull the child bodies
10
+ // through the sync engine so a later re-materialization attaches them to the
11
+ // parent. If this stops forwarding the child ids, related fields come back
12
+ // empty (threads render with an "Anonymous" author, comments vanish) even
13
+ // though the server wrote the edges correctly.
14
+
15
+ function makeSync() {
16
+ const logger: any = {
17
+ child: () => logger,
18
+ debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
19
+ };
20
+ const remote: any = { query: vi.fn() };
21
+ const queryId = new RecordId('_00_query', 'h1');
22
+ const queryState: any = { config: { id: queryId, subqueryRemoteArray: [] } };
23
+ const dataModule: any = { getQueryByHash: vi.fn().mockReturnValue(queryState) };
24
+
25
+ const sync = new Sp00kySync(
26
+ {} as any, remote, {} as any, dataModule, {} as any, logger,
27
+ );
28
+
29
+ // syncEngine is constructed internally; replace it with a spy.
30
+ const syncRecords = vi.fn().mockResolvedValue(undefined);
31
+ (sync as any).syncEngine = { syncRecords };
32
+ // Pin the resolved per-user list_ref table (auth routing is out of scope here).
33
+ (sync as any).listRefTable = () => '_00_list_ref_user_x';
34
+
35
+ const run = (hash: string) => (sync as any).syncSubqueryChildren(hash) as Promise<void>;
36
+ return { remote, syncRecords, queryState, queryId, run };
37
+ }
38
+
39
+ describe('syncSubqueryChildren — related-child delivery', () => {
40
+ beforeEach(() => vi.clearAllMocks());
41
+
42
+ it('reads the subquery edges (parent IS NOT NONE) scoped to the query id', async () => {
43
+ const { remote, run, queryId } = makeSync();
44
+ remote.query.mockResolvedValue([[]]);
45
+
46
+ await run('h1');
47
+
48
+ expect(remote.query).toHaveBeenCalledWith(
49
+ buildSubqueryListRefSelect('_00_list_ref_user_x'),
50
+ { in: queryId }
51
+ );
52
+ });
53
+
54
+ it('forwards each subquery child id to the sync engine so its body is cached', async () => {
55
+ const { remote, syncRecords, run } = makeSync();
56
+ // Server has one author child edge for this query.
57
+ remote.query.mockResolvedValue([[
58
+ { out: new RecordId('user', 'u'), version: 1 },
59
+ ]]);
60
+
61
+ await run('h1');
62
+
63
+ expect(syncRecords).toHaveBeenCalledTimes(1);
64
+ const arg = syncRecords.mock.calls[0][0];
65
+ expect(arg.added).toHaveLength(1);
66
+ expect(encodeRecordId(arg.added[0].id)).toBe('user:u');
67
+ expect(arg.added[0].version).toBe(1);
68
+ expect(arg.removed).toEqual([]); // shared child bodies are never deleted here
69
+ });
70
+
71
+ it('is idempotent: unchanged edges do not refetch bodies', async () => {
72
+ const { remote, syncRecords, run, queryState } = makeSync();
73
+ queryState.config.subqueryRemoteArray = [['user:u', 1]];
74
+ remote.query.mockResolvedValue([[
75
+ { out: new RecordId('user', 'u'), version: 1 },
76
+ ]]);
77
+
78
+ await run('h1');
79
+
80
+ expect(syncRecords).not.toHaveBeenCalled();
81
+ });
82
+ });