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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { ANON_USER_ID, bucketIdForUser, listRefTableFor, sanitizeUserId } from './ref-tables';
4
+
5
+ describe('listRefTableFor', () => {
6
+ it('returns global table in single mode regardless of user', () => {
7
+ expect(listRefTableFor('single', 'user:abc')).toBe('_00_list_ref');
8
+ expect(listRefTableFor('single', null)).toBe('_00_list_ref');
9
+ expect(listRefTableFor('single', undefined)).toBe('_00_list_ref');
10
+ });
11
+
12
+ it('returns per-user table in dedicated mode with valid user id', () => {
13
+ expect(listRefTableFor('dedicated', 'user:abc')).toBe(
14
+ '_00_list_ref_user_abc'
15
+ );
16
+ });
17
+
18
+ it('accepts a RecordId object in dedicated mode', () => {
19
+ const rid = new RecordId('user', 'def');
20
+ expect(listRefTableFor('dedicated', rid)).toBe('_00_list_ref_user_def');
21
+ });
22
+
23
+ it('falls back to global in dedicated mode when user id is missing', () => {
24
+ expect(listRefTableFor('dedicated', null)).toBe('_00_list_ref');
25
+ expect(listRefTableFor('dedicated', undefined)).toBe('_00_list_ref');
26
+ });
27
+
28
+ it('falls back to global in dedicated mode when user id has invalid chars', () => {
29
+ // SurrealDB table identifiers only accept alphanumerics + underscore.
30
+ expect(listRefTableFor('dedicated', 'user:abc-with-dash')).toBe(
31
+ '_00_list_ref'
32
+ );
33
+ expect(listRefTableFor('dedicated', 'user:abc.dot')).toBe('_00_list_ref');
34
+ });
35
+
36
+ it('routes the anon sentinel to the shared anon table in both modes', () => {
37
+ expect(listRefTableFor('dedicated', ANON_USER_ID)).toBe('_00_list_ref_anon');
38
+ expect(listRefTableFor('single', ANON_USER_ID)).toBe('_00_list_ref_anon');
39
+ // A real user whose id sanitizes to "anon" still carries the user: prefix,
40
+ // so it never collides with the bare sentinel.
41
+ expect(listRefTableFor('dedicated', 'user:anon')).toBe(
42
+ '_00_list_ref_user_anon'
43
+ );
44
+ });
45
+ });
46
+
47
+ describe('sanitizeUserId', () => {
48
+ it('strips the user: prefix', () => {
49
+ expect(sanitizeUserId('user:abc123')).toBe('abc123');
50
+ });
51
+
52
+ it('accepts plain ids without the user: prefix', () => {
53
+ expect(sanitizeUserId('abc123')).toBe('abc123');
54
+ });
55
+
56
+ it('accepts RecordId objects', () => {
57
+ expect(sanitizeUserId(new RecordId('user', 'xyz'))).toBe('xyz');
58
+ });
59
+
60
+ it('returns null for invalid id shapes', () => {
61
+ expect(sanitizeUserId(null)).toBeNull();
62
+ expect(sanitizeUserId(undefined)).toBeNull();
63
+ expect(sanitizeUserId('user:')).toBeNull();
64
+ expect(sanitizeUserId('user:has-dash')).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe('bucketIdForUser', () => {
69
+ it('routes signed-out sessions to the anon bucket', () => {
70
+ expect(bucketIdForUser(null)).toBe(ANON_USER_ID);
71
+ expect(bucketIdForUser(undefined)).toBe(ANON_USER_ID);
72
+ expect(bucketIdForUser(ANON_USER_ID)).toBe(ANON_USER_ID);
73
+ });
74
+
75
+ it('uses the sanitized id for valid users', () => {
76
+ expect(bucketIdForUser('user:abc123')).toBe('abc123');
77
+ expect(bucketIdForUser(new RecordId('user', 'xyz'))).toBe('xyz');
78
+ });
79
+
80
+ it('gives unsanitizable ids a deterministic per-user bucket, never anon', () => {
81
+ const a = bucketIdForUser('user:has-dash');
82
+ const b = bucketIdForUser('user:has-dash');
83
+ const c = bucketIdForUser('user:other-dash');
84
+ // Falling back to the shared anon bucket here would recreate the
85
+ // cross-user local-cache leak this helper exists to prevent.
86
+ expect(a).not.toBe(ANON_USER_ID);
87
+ expect(a).toBe(b);
88
+ expect(a).not.toBe(c);
89
+ expect(a).toMatch(/^u[0-9a-f]+$/);
90
+ });
91
+ });
@@ -0,0 +1,88 @@
1
+ // Client-side mirror of `packages/ssp-protocol/src/lib.rs`'s
2
+ // `query_table_for` / `list_ref_table_for`. Same naming convention so
3
+ // the LIVE subscription, the initial-fetch read, and the SSP's writes
4
+ // all land on the same table.
5
+ //
6
+ // The mode is currently hardcoded to `dedicated` because that's the
7
+ // only mode the e2e suite exercises and threading the value through
8
+ // codegen wasn't necessary to land the cross-session fix. If single
9
+ // mode ever needs to be exposed from the TS client too, the SSP server
10
+ // already reads it from `SPKY_SSP_REF_MODE`; add a matching codegen
11
+ // export then.
12
+
13
+ import { cyrb53 } from '@spooky-sync/query-builder';
14
+
15
+ export type RefMode = 'single' | 'dedicated';
16
+
17
+ /**
18
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
19
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
20
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
21
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
22
+ */
23
+ export const ANON_USER_ID = 'anon';
24
+
25
+ /**
26
+ * Default ref-storage mode for this client build. Mirrors the SSP's
27
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
28
+ * box.
29
+ */
30
+ export const DEFAULT_REF_MODE: RefMode = 'dedicated';
31
+
32
+ /**
33
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
34
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
35
+ * the id is missing the `user:` prefix or contains characters that
36
+ * aren't valid in a SurrealDB table identifier — the server-side
37
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
38
+ *
39
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
40
+ * objects (which only stringify cleanly via `.toString()`), since
41
+ * `AuthService` passes the record-id object as-is to its subscribers.
42
+ */
43
+ export function sanitizeUserId(userId: unknown): string | null {
44
+ if (userId === null || userId === undefined) return null;
45
+ const asString =
46
+ typeof userId === 'string'
47
+ ? userId
48
+ : typeof (userId as { toString?: unknown }).toString === 'function'
49
+ ? (userId as { toString: () => string }).toString()
50
+ : null;
51
+ if (!asString) return null;
52
+ const raw = asString.startsWith('user:') ? asString.slice('user:'.length) : asString;
53
+ if (raw.length === 0) return null;
54
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
55
+ return raw;
56
+ }
57
+
58
+ /**
59
+ * Resolve the LOCAL storage bucket id for a user. Every user gets their own
60
+ * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
61
+ * query state, and the mutation outbox never leak across accounts on a shared
62
+ * device. Signed-out sessions share the `anon` bucket.
63
+ *
64
+ * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
65
+ * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
66
+ * authenticated user in the shared bucket and recreate the cross-user leak.
67
+ */
68
+ export function bucketIdForUser(userId: unknown): string {
69
+ if (userId === null || userId === undefined || userId === ANON_USER_ID) return ANON_USER_ID;
70
+ const uid = sanitizeUserId(userId);
71
+ if (uid) return uid;
72
+ return `u${cyrb53(String(userId)).toString(16)}`;
73
+ }
74
+
75
+ /**
76
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
77
+ * back to the global `_00_list_ref` when sanitization fails or in
78
+ * single mode.
79
+ */
80
+ export function listRefTableFor(mode: RefMode, userId: unknown): string {
81
+ // Anonymous clients (flag-enabled) share one dedicated table in both modes —
82
+ // checked before the mode split so it never lands on the per-user or the
83
+ // auth-gated global table. Matches `ssp_protocol::list_ref_table_for`.
84
+ if (userId === ANON_USER_ID) return '_00_list_ref_anon';
85
+ if (mode === 'single') return '_00_list_ref';
86
+ const uid = sanitizeUserId(userId);
87
+ return uid ? `_00_list_ref_user_${uid}` : '_00_list_ref';
88
+ }
@@ -1,18 +1,21 @@
1
- import { RecordId } from 'surrealdb';
2
- import { SchemaStructure } from '@spooky-sync/query-builder';
3
- import { RemoteDatabaseService } from '../../services/database/index';
4
- import { CacheModule, CacheRecord, RecordWithId } from '../cache/index';
5
- import { RecordVersionDiff } from '../../types';
6
- import { Logger } from '../../services/logger/index';
1
+ import type { RecordId } from 'surrealdb';
2
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
3
+ import type { RemoteDatabaseService } from '../../services/database/index';
4
+ import type { CacheModule, CacheRecord, RecordWithId } from '../cache/index';
5
+ import type { RecordVersionDiff } from '../../types';
6
+ import type { Logger } from '../../services/logger/index';
7
7
  import { SyncEventTypes, createSyncEventSystem } from './events/index';
8
8
  import { encodeRecordId } from '../../utils/index';
9
9
  import { cleanRecord } from '../../utils/parser';
10
10
 
11
+ /** Ids per remote `SELECT * FROM $ids` round trip. */
12
+ const FETCH_CHUNK = 500;
13
+
11
14
  /**
12
15
  * SyncEngine handles the core sync operations: fetching remote records,
13
16
  * caching them locally, and ingesting into DBSP.
14
17
  *
15
- * This is extracted from SpookySync to separate "how to sync" from "when to sync".
18
+ * This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
16
19
  */
17
20
  export class SyncEngine {
18
21
  private logger: Logger;
@@ -24,7 +27,7 @@ export class SyncEngine {
24
27
  private schema: SchemaStructure,
25
28
  logger: Logger
26
29
  ) {
27
- this.logger = logger.child({ service: 'SpookySync:SyncEngine' });
30
+ this.logger = logger.child({ service: 'Sp00kySync:SyncEngine' });
28
31
  }
29
32
 
30
33
  /**
@@ -32,7 +35,9 @@ export class SyncEngine {
32
35
  * Main entry point for sync operations.
33
36
  * Uses batch processing to minimize events emitted.
34
37
  */
35
- async syncRecords(diff: RecordVersionDiff): Promise<void> {
38
+ async syncRecords(
39
+ diff: RecordVersionDiff
40
+ ): Promise<{ remoteFetchMs: number; stillRemoteIds: string[] }> {
36
41
  const { added, updated, removed } = diff;
37
42
 
38
43
  this.logger.debug(
@@ -40,124 +45,201 @@ export class SyncEngine {
40
45
  added,
41
46
  updated,
42
47
  removed,
43
- Category: 'spooky-client::SyncEngine::syncRecords',
48
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
44
49
  },
45
50
  'SyncEngine.syncRecords diff'
46
51
  );
47
52
 
48
- // Handle removed records: verify they don't exist remotely before deleting locally
53
+ // Handle removed records: verify they don't exist remotely before deleting
54
+ // locally. Returns ids that LEFT the view's list_ref but still exist upstream
55
+ // (so they weren't deleted) — the caller converges localArray to drop them.
56
+ let stillRemoteIds: string[] = [];
49
57
  if (removed.length > 0) {
50
- await this.handleRemovedRecords(removed);
58
+ stillRemoteIds = await this.handleRemovedRecords(removed);
51
59
  }
52
60
 
53
- // Fetch added/updated records from remote
54
- const toFetch = [...added, ...updated];
55
- const idsToFetch = toFetch.map((x) => x.id);
56
- if (idsToFetch.length === 0) {
57
- return;
61
+ // Fetch added/updated records from remote. Skip ids whose body the local
62
+ // store already holds at this version (the circuit prime seeded the memo
63
+ // from `_00_rv`): after a reload every id in the server's list_ref used to
64
+ // classify as `added` against an empty circuit, and this fetched the
65
+ // whole working set again.
66
+ const toFetch = [...added, ...updated].filter((item) => {
67
+ const localVersion = this.cache.lookup(encodeRecordId(item.id));
68
+ return !(localVersion && item.version <= localVersion);
69
+ });
70
+ if (toFetch.length === 0) {
71
+ return { remoteFetchMs: 0, stillRemoteIds };
58
72
  }
59
73
 
60
- const [remoteResults] = await this.remote.query<[RecordWithId[]]>(
61
- "SELECT (SELECT * FROM ONLY <record>$parent.id) AS record, (SELECT version FROM ONLY _spooky_version WHERE record_id = <record>$parent.id)['version'] as spooky_rv FROM $idsToFetch",
62
- { idsToFetch }
63
- );
64
- console.log('remoteResults>', remoteResults);
65
- // Prepare batch for cache (which handles both DB and DBSP)
66
- const cacheBatch: CacheRecord[] = [];
67
-
68
- for (const { spooky_rv, record } of remoteResults) {
69
- if (!record?.id) {
70
- this.logger.warn(
71
- {
72
- record,
73
- idsToFetch,
74
- Category: 'spooky-client::SyncEngine::syncRecords',
75
- },
76
- 'Remote record has no id. Skipping record'
77
- );
78
- continue;
74
+ // Build a version map from the diff (versions come from _00_list_ref)
75
+ const versionMap = new Map<string, number>();
76
+ for (const item of toFetch) {
77
+ versionMap.set(encodeRecordId(item.id), item.version);
78
+ }
79
+ const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
80
+
81
+ // Fetch records from remote — avoid SELECT *, <subquery> FROM $param
82
+ // pattern which drops the * fields in SurrealDB v3 (known bug).
83
+ // Versions are already known from the diff's list_ref data.
84
+ //
85
+ // Chunked so a genuinely cold load lands progressively: one response
86
+ // holding thousands of bodies, then one transaction MERGEing all of them,
87
+ // was a single main-thread stall and a memory spike on top of it.
88
+ let remoteFetchMs = 0;
89
+ const remoteResults: RecordWithId[] = [];
90
+ for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
91
+ const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
92
+ const remoteFetchStart = performance.now();
93
+ const [chunkResults] = await this.remote.query<[RecordWithId[]]>(
94
+ 'SELECT * FROM $idsToFetch',
95
+ { idsToFetch }
96
+ );
97
+ remoteFetchMs += performance.now() - remoteFetchStart;
98
+
99
+ // Prepare batch for cache (which handles both DB and DBSP)
100
+ const cacheBatch: CacheRecord[] = [];
101
+ for (const record of chunkResults ?? []) {
102
+ if (!record?.id) {
103
+ this.logger.warn(
104
+ {
105
+ record,
106
+ idsToFetch,
107
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
108
+ },
109
+ 'Remote record has no id (possibly deleted). Skipping record'
110
+ );
111
+ continue;
112
+ }
113
+ remoteResults.push(record);
114
+ const fullId = encodeRecordId(record.id);
115
+ const table = record.id.table.toString();
116
+ const version = versionMap.get(fullId) ?? 0;
117
+
118
+ const localVersion = this.cache.lookup(fullId);
119
+ if (localVersion && version <= localVersion) {
120
+ this.logger.info(
121
+ {
122
+ recordId: fullId,
123
+ version,
124
+ localVersion,
125
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
126
+ },
127
+ 'Local version is higher than remote version. Skipping record'
128
+ );
129
+ continue;
130
+ }
131
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
132
+ const cleanedRecord = tableSchema
133
+ ? cleanRecord(tableSchema.columns, record)
134
+ : record;
135
+
136
+ cacheBatch.push({
137
+ table,
138
+ op: addedIds.has(fullId) ? 'CREATE' : 'UPDATE',
139
+ record: cleanedRecord as RecordWithId,
140
+ version,
141
+ });
79
142
  }
80
- const fullId = encodeRecordId(record.id);
81
- const table = record.id.table.toString();
82
- const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
83
143
 
84
- const localVersion = this.cache.lookup(fullId);
85
- if (localVersion && spooky_rv <= localVersion) {
86
- this.logger.info(
87
- {
88
- recordId: fullId,
89
- version: spooky_rv,
90
- localVersion,
91
- Category: 'spooky-client::SyncEngine::syncRecords',
92
- },
93
- 'Local version is higher than remote version. Skipping record'
94
- );
95
- continue;
144
+ // Use CacheModule to handle both local DB and DBSP ingestion
145
+ if (cacheBatch.length > 0) {
146
+ await this.cache.saveBatch(cacheBatch);
96
147
  }
97
- const tableSchema = this.schema.tables.find((t) => t.name === table);
98
- const cleanedRecord = tableSchema
99
- ? cleanRecord(tableSchema.columns, record)
100
- : record;
101
-
102
- cacheBatch.push({
103
- table,
104
- op: isAdded ? 'CREATE' : 'UPDATE',
105
- record: cleanedRecord as RecordWithId,
106
- version: spooky_rv,
107
- });
108
- }
109
-
110
- // Use CacheModule to handle both local DB and DBSP ingestion
111
- if (cacheBatch.length > 0) {
112
- await this.cache.saveBatch(cacheBatch);
113
148
  }
114
149
 
115
150
  this.events.emit(SyncEventTypes.RemoteDataIngested, {
116
151
  records: remoteResults,
117
152
  });
153
+
154
+ return { remoteFetchMs, stillRemoteIds };
118
155
  }
119
156
 
120
157
  /**
121
158
  * Handle records that exist locally but not in remote array.
159
+ *
160
+ * "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
161
+ * longer references a record that exists locally. That can mean the row
162
+ * was genuinely deleted upstream — but it can also be a benign race
163
+ * (e.g. a record we just created hasn't propagated into the SSP's
164
+ * incantation list yet). Before deleting locally we verify against
165
+ * upstream SurrealDB: if the row still exists there, skip the delete.
166
+ *
167
+ * On verification failure we skip deletion too. Losing a stale local
168
+ * row to a later sync round is recoverable; deleting a fresh row that
169
+ * upstream still has is not.
122
170
  */
123
- private async handleRemovedRecords(removed: RecordId[]): Promise<void> {
171
+ private async handleRemovedRecords(removed: RecordId[]): Promise<string[]> {
124
172
  this.logger.debug(
125
173
  {
126
174
  removed: removed.map((r) => r.toString()),
127
- Category: 'spooky-client::SyncEngine::handleRemovedRecords',
175
+ Category: 'sp00ky-client::SyncEngine::handleRemovedRecords',
128
176
  },
129
177
  'Checking removed records'
130
178
  );
131
179
 
132
- let existingRemoteIds = new Set<string>();
180
+ // Confirm which of the "removed" ids still exist remotely by selecting the
181
+ // records directly from the id array (the records ARE the FROM target).
182
+ //
183
+ // The exact query form matters on SurrealDB v3.x:
184
+ // - `WHERE id IN $ids` is broken: record-id `IN` matches nothing, so every
185
+ // removed id looked gone and live local records got deleted (a fresh
186
+ // collection vanished mid-session). Do NOT use `IN`.
187
+ // - `SELECT id FROM $ids` — a FIELD projection over a record-id ARRAY —
188
+ // errors "Specify a database to use" on the deployed engine. The catch
189
+ // below then swallowed it and skipped EVERY deletion, so nothing could be
190
+ // deleted anywhere (games, comments, …). Do NOT project a field over the
191
+ // array. (`SELECT * FROM $ids` works but pulls full records — wasteful.)
192
+ // - `SELECT VALUE id FROM $ids` works: a flat array of ids with a NONE entry
193
+ // for each id that no longer exists. We filter the NONE entries out; the
194
+ // survivors are the ids still present upstream.
195
+ let existingRemoteIds: Set<string>;
133
196
  try {
134
- const [existingRemote] = await this.remote.query<[{ id: RecordId }[]]>('SELECT id FROM $ids', {
135
- ids: removed,
136
- });
137
- existingRemoteIds = new Set(existingRemote.map((r) => encodeRecordId(r.id)));
138
- } catch {
139
- // If remote check fails (e.g., SurrealDB parameter serialization issue),
140
- // proceed with deletion the caller has already determined these should be removed
141
- this.logger.debug(
142
- { Category: 'spooky-client::SyncEngine::handleRemovedRecords' },
143
- 'Remote existence check failed, proceeding with deletion'
197
+ const [existing] = await this.remote.query<[(RecordId | null | undefined)[]]>(
198
+ 'SELECT VALUE id FROM $ids',
199
+ { ids: removed }
200
+ );
201
+ existingRemoteIds = new Set(
202
+ (existing ?? [])
203
+ .filter((id): id is RecordId => id != null)
204
+ .map((id) => encodeRecordId(id))
144
205
  );
206
+ } catch (err) {
207
+ // Verification failed. Skip deletion entirely — the next sync
208
+ // round re-derives the diff and we get another shot. The
209
+ // alternative (delete on uncertainty) destroys freshly-created
210
+ // rows when the SSP hasn't yet refreshed `_00_list_ref`.
211
+ this.logger.warn(
212
+ {
213
+ err,
214
+ removed: removed.map((r) => r.toString()),
215
+ Category: 'sp00ky-client::SyncEngine::handleRemovedRecords',
216
+ },
217
+ 'Remote existence check failed, skipping deletion to avoid clobbering fresh data'
218
+ );
219
+ return [];
145
220
  }
146
221
 
222
+ // Ids that left the view's list_ref but STILL exist upstream — not deletions,
223
+ // just a view-membership change (e.g. a record whose field changed so it no
224
+ // longer matches the query). The caller drops these from `localArray` so the
225
+ // poll's diff stops re-flagging them every tick (the `job:` churn).
226
+ const stillRemoteIds: string[] = [];
147
227
  for (const recordId of removed) {
148
228
  const recordIdStr = encodeRecordId(recordId);
149
229
  if (!existingRemoteIds.has(recordIdStr)) {
150
230
  this.logger.debug(
151
231
  {
152
232
  recordId: recordIdStr,
153
- Category: 'spooky-client::SyncEngine::handleRemovedRecords',
233
+ Category: 'sp00ky-client::SyncEngine::handleRemovedRecords',
154
234
  },
155
235
  'Deleting confirmed removed record'
156
236
  );
157
-
158
237
  // Use CacheModule to handle both local DB and DBSP deletion
159
238
  await this.cache.delete(recordId.table.name, recordIdStr);
239
+ } else {
240
+ stillRemoteIds.push(recordIdStr);
160
241
  }
161
242
  }
243
+ return stillRemoteIds;
162
244
  }
163
245
  }
@@ -1,5 +1,6 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
2
- import { RecordVersionArray } from '../../../types';
1
+ import type { EventDefinition, EventSystem } from '../../../events/index';
2
+ import { createEventSystem } from '../../../events/index';
3
+ import type { RecordVersionArray, SyncHealth } from '../../../types';
3
4
 
4
5
  export const SyncQueueEventTypes = {
5
6
  MutationEnqueued: 'MUTATION_ENQUEUED',
@@ -36,6 +37,7 @@ export const SyncEventTypes = {
36
37
  QueryUpdated: 'SYNC_QUERY_UPDATED',
37
38
  RemoteDataIngested: 'SYNC_REMOTE_DATA_INGESTED',
38
39
  MutationRolledBack: 'SYNC_MUTATION_ROLLED_BACK',
40
+ SyncHealthChanged: 'SYNC_HEALTH_CHANGED',
39
41
  } as const;
40
42
 
41
43
  export type SyncEventTypeMap = {
@@ -64,6 +66,10 @@ export type SyncEventTypeMap = {
64
66
  error: string;
65
67
  }
66
68
  >;
69
+ [SyncEventTypes.SyncHealthChanged]: EventDefinition<
70
+ typeof SyncEventTypes.SyncHealthChanged,
71
+ SyncHealth
72
+ >;
67
73
  };
68
74
 
69
75
  export type SyncEventSystem = EventSystem<SyncEventTypeMap>;
@@ -73,5 +79,6 @@ export function createSyncEventSystem(): SyncEventSystem {
73
79
  SyncEventTypes.QueryUpdated,
74
80
  SyncEventTypes.RemoteDataIngested,
75
81
  SyncEventTypes.MutationRolledBack,
82
+ SyncEventTypes.SyncHealthChanged,
76
83
  ]);
77
84
  }