@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70

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.
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildWindowMaterialization } from './window-query';
3
+
4
+ describe('buildWindowMaterialization', () => {
5
+ it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
6
+ const surql =
7
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
8
+ const r = buildWindowMaterialization(surql);
9
+ expect(r).not.toBeNull();
10
+ expect(r!.query).toBe('SELECT * FROM $__win ORDER BY sort_index asc, date desc');
11
+ });
12
+
13
+ it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
14
+ const surql =
15
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 0;';
16
+ expect(buildWindowMaterialization(surql)).toBeNull();
17
+ });
18
+
19
+ it('returns null when there is no START clause', () => {
20
+ expect(
21
+ buildWindowMaterialization('SELECT * FROM game WHERE database = $database LIMIT 30;')
22
+ ).toBeNull();
23
+ expect(buildWindowMaterialization('SELECT * FROM game;')).toBeNull();
24
+ });
25
+
26
+ it('preserves a custom projection', () => {
27
+ const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
28
+ expect(buildWindowMaterialization(surql)!.query).toBe(
29
+ 'SELECT id, white, black FROM $__win ORDER BY date desc'
30
+ );
31
+ });
32
+
33
+ it('omits ORDER BY when the query had none', () => {
34
+ const surql = 'SELECT * FROM game LIMIT 30 START 30;';
35
+ expect(buildWindowMaterialization(surql)!.query).toBe('SELECT * FROM $__win');
36
+ });
37
+
38
+ it('ignores FROM/ORDER BY/LIMIT/START inside subqueries (paren-aware)', () => {
39
+ const surql =
40
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
41
+ 'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
42
+ expect(buildWindowMaterialization(surql)!.query).toBe(
43
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM $__win ORDER BY sort_index asc'
44
+ );
45
+ });
46
+
47
+ it('does not treat a START inside a string literal as the offset', () => {
48
+ const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
49
+ const r = buildWindowMaterialization(surql);
50
+ expect(r!.query).toBe('SELECT * FROM $__win ORDER BY date desc');
51
+ });
52
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
3
+ * materialized from an explicit record-id set — the window the SSP already
4
+ * computed — instead of re-running the original query (with its `START m`)
5
+ * against the shared local DB.
6
+ *
7
+ * Why: `DataModule.processStreamUpdate` materializes a query's rows by
8
+ * re-querying the local in-browser SurrealDB with the original surql. For an
9
+ * offset query that re-applies `START m` against the *shared* local store —
10
+ * which, with sparse windowing, may hold only this window's rows — so it skips
11
+ * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
12
+ * materialized view (`StreamUpdate.localArray`) is exactly this window's row
13
+ * ids, so we select those ids directly and re-apply the original `ORDER BY` for
14
+ * stable display order.
15
+ *
16
+ * Returns `null` for non-offset queries (`START` absent or 0) — the caller
17
+ * keeps the normal re-query path, so only the broken case changes behavior.
18
+ *
19
+ * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
20
+ * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
21
+ * projection are preserved verbatim (the scanner is paren/quote aware), with
22
+ * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
23
+ * shape — such windowed+subquery queries are rare and were already returning 0,
24
+ * so this is no regression.
25
+ */
26
+ export function buildWindowMaterialization(
27
+ surql: string,
28
+ idsParam = '__win'
29
+ ): { query: string } | null {
30
+ const kw = scanTopLevelClauses(surql);
31
+ if (kw.startValue === null || kw.startValue <= 0) return null;
32
+ if (kw.fromIndex === null) return null;
33
+
34
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd(); // "SELECT <projection>"
35
+
36
+ let orderBy = '';
37
+ if (kw.orderByIndex !== null) {
38
+ const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
39
+ (n): n is number => n !== null && n > kw.orderByIndex!
40
+ );
41
+ const end = Math.min(...ends);
42
+ orderBy = ' ' + surql.slice(kw.orderByIndex, end).trim();
43
+ }
44
+
45
+ return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
46
+ }
47
+
48
+ interface TopLevelClauses {
49
+ fromIndex: number | null;
50
+ orderByIndex: number | null;
51
+ limitIndex: number | null;
52
+ startIndex: number | null;
53
+ startValue: number | null;
54
+ semicolonIndex: number | null;
55
+ }
56
+
57
+ // Single pass over the query, tracking paren depth and single-quoted strings so
58
+ // that clauses inside subqueries (which live in parens) are ignored — only the
59
+ // outermost (depth-0) clause keywords are recorded.
60
+ function scanTopLevelClauses(sql: string): TopLevelClauses {
61
+ const out: TopLevelClauses = {
62
+ fromIndex: null,
63
+ orderByIndex: null,
64
+ limitIndex: null,
65
+ startIndex: null,
66
+ startValue: null,
67
+ semicolonIndex: null,
68
+ };
69
+ let depth = 0;
70
+ let inStr = false;
71
+
72
+ for (let i = 0; i < sql.length; i++) {
73
+ const ch = sql[i];
74
+ if (inStr) {
75
+ if (ch === '\\') i++; // skip escaped char
76
+ else if (ch === "'") inStr = false;
77
+ continue;
78
+ }
79
+ if (ch === "'") { inStr = true; continue; }
80
+ if (ch === '(') { depth++; continue; }
81
+ if (ch === ')') { depth--; continue; }
82
+ if (depth !== 0) continue;
83
+ if (ch === ';' && out.semicolonIndex === null) { out.semicolonIndex = i; continue; }
84
+
85
+ // Only test keywords at a word boundary.
86
+ if (!isWordBoundary(sql, i)) continue;
87
+ if (out.fromIndex === null && matchKeyword(sql, i, 'FROM')) { out.fromIndex = i; continue; }
88
+ // FROM must come before the trailing clauses; only record them once seen.
89
+ if (out.fromIndex === null) continue;
90
+ if (out.orderByIndex === null && matchKeyword(sql, i, 'ORDER BY')) { out.orderByIndex = i; continue; }
91
+ if (out.limitIndex === null && matchKeyword(sql, i, 'LIMIT')) { out.limitIndex = i; continue; }
92
+ if (out.startIndex === null && matchKeyword(sql, i, 'START')) {
93
+ out.startIndex = i;
94
+ out.startValue = readNumberAfter(sql, i + 'START'.length);
95
+ continue;
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function isWordBoundary(sql: string, i: number): boolean {
102
+ if (i === 0) return true;
103
+ return !/[A-Za-z0-9_]/.test(sql[i - 1]);
104
+ }
105
+
106
+ // Case-insensitive keyword match where internal whitespace (e.g. "ORDER BY")
107
+ // matches any run of whitespace, and the keyword ends on a non-word char.
108
+ function matchKeyword(sql: string, i: number, keyword: string): boolean {
109
+ const parts = keyword.split(' ');
110
+ let pos = i;
111
+ for (let p = 0; p < parts.length; p++) {
112
+ const word = parts[p];
113
+ if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
114
+ pos += word.length;
115
+ if (p < parts.length - 1) {
116
+ const wsStart = pos;
117
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
118
+ if (pos === wsStart) return false; // required whitespace
119
+ }
120
+ }
121
+ // Must end on a non-word char (or end of string).
122
+ return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
123
+ }
124
+
125
+ function readNumberAfter(sql: string, from: number): number | null {
126
+ let pos = from;
127
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
128
+ const m = /^\d+/.exec(sql.slice(pos));
129
+ return m ? parseInt(m[0], 10) : null;
130
+ }
@@ -16,11 +16,20 @@ export interface DevToolsEvent {
16
16
  import type { DataModule } from '../data/index';
17
17
  import type { AuthService } from '../auth/index';
18
18
  import { AuthEventTypes } from '../auth/events/index';
19
+ import {
20
+ type BackendInfo,
21
+ emptyBackendInfo,
22
+ parseBackendInfo,
23
+ } from './versions';
19
24
 
20
25
  export class DevToolsService implements StreamUpdateReceiver {
21
26
  private eventsHistory: DevToolsEvent[] = [];
22
27
  private eventIdCounter = 0;
23
- private version = '1.0.0';
28
+ // Real bundled frontend version (injected at build time via tsdown `define`).
29
+ private version = __SP00KY_CORE_VERSION__;
30
+ // Backend stack info (versions + per-entity status), read via the
31
+ // `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
32
+ private backendInfo: BackendInfo = emptyBackendInfo();
24
33
 
25
34
  constructor(
26
35
  private databaseService: LocalDatabaseService,
@@ -37,9 +46,35 @@ export class DevToolsService implements StreamUpdateReceiver {
37
46
  this.notifyDevTools();
38
47
  });
39
48
 
49
+ // Fire-and-forget backend version discovery; re-push state when it lands.
50
+ void this.refreshBackendVersions();
51
+
40
52
  this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
41
53
  }
42
54
 
55
+ /**
56
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
57
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
58
+ * Never throws: on failure the info stays empty/'unavailable'.
59
+ */
60
+ private async refreshBackendVersions(): Promise<void> {
61
+ try {
62
+ // `RETURN fn::spooky::info()` → one statement result: the /info entity array.
63
+ const result = await this.remoteDatabaseService.query<unknown[]>(
64
+ 'RETURN fn::spooky::info()'
65
+ );
66
+ const first = Array.isArray(result) ? result[0] : result;
67
+ this.backendInfo = parseBackendInfo(first);
68
+ } catch (err) {
69
+ this.logger.debug(
70
+ { err, Category: 'sp00ky-client::DevToolsService::versions' },
71
+ 'fn::spooky::info() unavailable; backend versions stay unavailable'
72
+ );
73
+ this.backendInfo = emptyBackendInfo();
74
+ }
75
+ this.notifyDevTools();
76
+ }
77
+
43
78
  // Get active queries directly from DataManager (single source of truth)
44
79
  private getActiveQueries(): Map<number, any> {
45
80
  const result = new Map<number, any>();
@@ -51,6 +86,10 @@ export class DevToolsService implements StreamUpdateReceiver {
51
86
  result.set(queryHash, {
52
87
  queryHash,
53
88
  status: 'active',
89
+ // Runtime fetch status, distinct from the `status: 'active'`
90
+ // registration flag above. `fetchStatus` is 'idle' | 'fetching'.
91
+ fetchStatus: q.status,
92
+ isFetching: q.status === 'fetching',
54
93
  createdAt:
55
94
  q.config.lastActiveAt instanceof Date
56
95
  ? q.config.lastActiveAt.getTime()
@@ -63,6 +102,10 @@ export class DevToolsService implements StreamUpdateReceiver {
63
102
  data: q.records,
64
103
  localArray: q.config.localArray,
65
104
  remoteArray: q.config.remoteArray,
105
+ // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
106
+ // remote record fetch, frontend reconcile, registration). Flows to both
107
+ // the DevTools panel and the MCP (which returns activeQueries verbatim).
108
+ timings: this.dataManager.phaseTimings(q),
66
109
  });
67
110
  });
68
111
  return result;
@@ -160,6 +203,15 @@ export class DevToolsService implements StreamUpdateReceiver {
160
203
  userId: this.authService.currentUser?.id,
161
204
  },
162
205
  version: this.version,
206
+ versions: {
207
+ frontend: {
208
+ core: __SP00KY_CORE_VERSION__,
209
+ wasm: __SP00KY_WASM_VERSION__,
210
+ surrealdb: __SP00KY_SURREAL_VERSION__,
211
+ },
212
+ backend: this.backendInfo.versions,
213
+ entities: this.backendInfo.entities,
214
+ },
163
215
  database: {
164
216
  tables: this.schema.tables.map((t) => t.name),
165
217
  tableData: {},
@@ -236,6 +288,7 @@ export class DevToolsService implements StreamUpdateReceiver {
236
288
  this.eventsHistory = [];
237
289
  this.notifyDevTools();
238
290
  },
291
+ refreshVersions: () => this.refreshBackendVersions(),
239
292
  getTableData: async (tableName: string) => {
240
293
  try {
241
294
  // Returns the first statement result as T.
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ emptyBackendInfo,
4
+ emptyBackendVersions,
5
+ parseBackendInfo,
6
+ toEntityArray,
7
+ UNAVAILABLE,
8
+ } from './versions';
9
+
10
+ describe('toEntityArray', () => {
11
+ it('passes through an array, dropping non-objects', () => {
12
+ expect(toEntityArray([{ entity: 'ssp' }, null, 'x'])).toEqual([{ entity: 'ssp' }]);
13
+ });
14
+
15
+ it('wraps a single object', () => {
16
+ expect(toEntityArray({ entity: 'ssp' })).toEqual([{ entity: 'ssp' }]);
17
+ });
18
+
19
+ it('returns [] for null/undefined/primitives', () => {
20
+ expect(toEntityArray(null)).toEqual([]);
21
+ expect(toEntityArray(undefined)).toEqual([]);
22
+ expect(toEntityArray(42)).toEqual([]);
23
+ });
24
+ });
25
+
26
+ describe('parseBackendInfo', () => {
27
+ it('parses the singlenode shape (ssp only) incl. surrealdb_version', () => {
28
+ const { versions, entities } = parseBackendInfo([
29
+ { entity: 'ssp', version: '0.0.1-canary.69', surrealdb_version: '2.0.3', status: 'ready' },
30
+ ]);
31
+ expect(versions.ssp).toBe('0.0.1-canary.69');
32
+ expect(versions.surrealdb).toBe('2.0.3');
33
+ expect(versions.scheduler).toBe(UNAVAILABLE);
34
+ expect(entities).toHaveLength(1);
35
+ });
36
+
37
+ it('parses the cluster shape (scheduler + ssp + backend)', () => {
38
+ const { versions, entities } = parseBackendInfo([
39
+ { entity: 'scheduler', version: '0.9.0', surrealdb_version: '2.0.3', status: 'ready' },
40
+ { entity: 'ssp', version: '0.9.0', status: 'ready' },
41
+ { entity: 'backend', id: 'surrealdb', status: 'healthy' },
42
+ ]);
43
+ expect(versions.scheduler).toBe('0.9.0');
44
+ expect(versions.ssp).toBe('0.9.0');
45
+ expect(versions.surrealdb).toBe('2.0.3');
46
+ expect(entities).toHaveLength(3);
47
+ });
48
+
49
+ it('strips a leading surrealdb- prefix', () => {
50
+ const { versions } = parseBackendInfo([
51
+ { entity: 'ssp', version: '1.0.0', surrealdb_version: 'surrealdb-2.1.0' },
52
+ ]);
53
+ expect(versions.surrealdb).toBe('2.1.0');
54
+ });
55
+
56
+ it('takes surrealdb_version from whichever entity reports it', () => {
57
+ const { versions } = parseBackendInfo([
58
+ { entity: 'scheduler', version: '0.9.0' },
59
+ { entity: 'ssp', version: '0.9.0', surrealdb_version: '3.1.0' },
60
+ ]);
61
+ expect(versions.surrealdb).toBe('3.1.0');
62
+ });
63
+
64
+ it('tolerates a single object instead of an array', () => {
65
+ const { versions } = parseBackendInfo({ entity: 'ssp', version: '1.2.3' });
66
+ expect(versions.ssp).toBe('1.2.3');
67
+ });
68
+
69
+ it('returns all-unavailable / empty for null or garbage', () => {
70
+ expect(parseBackendInfo(null)).toEqual(emptyBackendInfo());
71
+ expect(parseBackendInfo('nope').versions).toEqual(emptyBackendVersions());
72
+ expect(parseBackendInfo([{ foo: 'bar' }]).versions).toEqual(emptyBackendVersions());
73
+ });
74
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Backend versions of the stack components, derived from the entity list the
3
+ * backend `/info` endpoint exposes (read via the `fn::spooky::info()` SurrealQL
4
+ * function). Any component that isn't reported degrades to `'unavailable'`.
5
+ */
6
+ export interface BackendVersions {
7
+ ssp: string;
8
+ scheduler: string;
9
+ surrealdb: string;
10
+ }
11
+
12
+ export const UNAVAILABLE = 'unavailable';
13
+
14
+ /**
15
+ * A single stack entity as reported by `/info` (one per ssp / scheduler /
16
+ * backend). Carries far more than versions — status, uptime, ip, views — so the
17
+ * DevTools can render the whole stack. Extra fields are preserved verbatim.
18
+ */
19
+ export interface BackendEntity {
20
+ entity: string;
21
+ id?: string;
22
+ ip?: string | null;
23
+ status?: string;
24
+ version?: string;
25
+ surrealdb_version?: string;
26
+ uptime_seconds?: number;
27
+ views?: number;
28
+ [key: string]: unknown;
29
+ }
30
+
31
+ export interface BackendInfo {
32
+ versions: BackendVersions;
33
+ entities: BackendEntity[];
34
+ }
35
+
36
+ export function emptyBackendVersions(): BackendVersions {
37
+ return { ssp: UNAVAILABLE, scheduler: UNAVAILABLE, surrealdb: UNAVAILABLE };
38
+ }
39
+
40
+ export function emptyBackendInfo(): BackendInfo {
41
+ return { versions: emptyBackendVersions(), entities: [] };
42
+ }
43
+
44
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
45
+ function normalizeServerVersion(v: string): string {
46
+ return String(v).replace(/^surrealdb-/i, '').trim();
47
+ }
48
+
49
+ /**
50
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
51
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
52
+ * how the result is unwrapped it may arrive as the array itself, a single
53
+ * object, or `null`. Tolerant of all three.
54
+ */
55
+ export function toEntityArray(raw: unknown): BackendEntity[] {
56
+ if (Array.isArray(raw)) return raw.filter((e): e is BackendEntity => !!e && typeof e === 'object');
57
+ if (raw && typeof raw === 'object') return [raw as BackendEntity];
58
+ return [];
59
+ }
60
+
61
+ /**
62
+ * Derive component versions + the full entity list from a `/info` entity array.
63
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
64
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
65
+ */
66
+ export function parseBackendInfo(raw: unknown): BackendInfo {
67
+ const entities = toEntityArray(raw);
68
+ const versions = emptyBackendVersions();
69
+
70
+ for (const entity of entities) {
71
+ const version = entity.version ? String(entity.version) : undefined;
72
+ if (entity.entity === 'ssp' && version) versions.ssp = version;
73
+ else if (entity.entity === 'scheduler' && version) versions.scheduler = version;
74
+
75
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) {
76
+ versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
77
+ }
78
+ }
79
+
80
+ return { versions, entities };
81
+ }
@@ -32,7 +32,9 @@ export class SyncEngine {
32
32
  * Main entry point for sync operations.
33
33
  * Uses batch processing to minimize events emitted.
34
34
  */
35
- async syncRecords(diff: RecordVersionDiff): Promise<void> {
35
+ async syncRecords(
36
+ diff: RecordVersionDiff
37
+ ): Promise<{ remoteFetchMs: number; stillRemoteIds: string[] }> {
36
38
  const { added, updated, removed } = diff;
37
39
 
38
40
  this.logger.debug(
@@ -45,16 +47,19 @@ export class SyncEngine {
45
47
  'SyncEngine.syncRecords diff'
46
48
  );
47
49
 
48
- // Handle removed records: verify they don't exist remotely before deleting locally
50
+ // Handle removed records: verify they don't exist remotely before deleting
51
+ // locally. Returns ids that LEFT the view's list_ref but still exist upstream
52
+ // (so they weren't deleted) — the caller converges localArray to drop them.
53
+ let stillRemoteIds: string[] = [];
49
54
  if (removed.length > 0) {
50
- await this.handleRemovedRecords(removed);
55
+ stillRemoteIds = await this.handleRemovedRecords(removed);
51
56
  }
52
57
 
53
58
  // Fetch added/updated records from remote
54
59
  const toFetch = [...added, ...updated];
55
60
  const idsToFetch = toFetch.map((x) => x.id);
56
61
  if (idsToFetch.length === 0) {
57
- return;
62
+ return { remoteFetchMs: 0, stillRemoteIds };
58
63
  }
59
64
 
60
65
  // Build a version map from the diff (versions come from _00_list_ref)
@@ -66,10 +71,12 @@ export class SyncEngine {
66
71
  // Fetch records from remote — avoid SELECT *, <subquery> FROM $param
67
72
  // pattern which drops the * fields in SurrealDB v3 (known bug).
68
73
  // Versions are already known from the diff's list_ref data.
74
+ const remoteFetchStart = performance.now();
69
75
  const [remoteResults] = await this.remote.query<[RecordWithId[]]>(
70
76
  'SELECT * FROM $idsToFetch',
71
77
  { idsToFetch }
72
78
  );
79
+ const remoteFetchMs = performance.now() - remoteFetchStart;
73
80
 
74
81
  // Prepare batch for cache (which handles both DB and DBSP)
75
82
  const cacheBatch: CacheRecord[] = [];
@@ -125,6 +132,8 @@ export class SyncEngine {
125
132
  this.events.emit(SyncEventTypes.RemoteDataIngested, {
126
133
  records: remoteResults,
127
134
  });
135
+
136
+ return { remoteFetchMs, stillRemoteIds };
128
137
  }
129
138
 
130
139
  /**
@@ -141,7 +150,7 @@ export class SyncEngine {
141
150
  * row to a later sync round is recoverable; deleting a fresh row that
142
151
  * upstream still has is not.
143
152
  */
144
- private async handleRemovedRecords(removed: RecordId[]): Promise<void> {
153
+ private async handleRemovedRecords(removed: RecordId[]): Promise<string[]> {
145
154
  this.logger.debug(
146
155
  {
147
156
  removed: removed.map((r) => r.toString()),
@@ -150,30 +159,28 @@ export class SyncEngine {
150
159
  'Checking removed records'
151
160
  );
152
161
 
153
- // Group by table so we can issue `SELECT id FROM type::table($t)
154
- // WHERE id IN $ids` per table. The earlier shape `SELECT id FROM
155
- // $ids` returns Internal/0 in SurrealDB 3.0 when `$ids` is bound as
156
- // an array of RecordIds; this form works because the array shows up
157
- // only in the WHERE clause, not as the FROM target.
158
- const byTable = new Map<string, RecordId[]>();
159
- for (const r of removed) {
160
- const list = byTable.get(r.table.name) ?? [];
161
- list.push(r);
162
- byTable.set(r.table.name, list);
163
- }
164
-
162
+ // Confirm which of the "removed" ids still exist remotely by selecting the
163
+ // records directly: `SELECT id FROM $ids` (the records to fetch ARE the
164
+ // FROM target).
165
+ //
166
+ // We must NOT use `WHERE id IN $ids` here: on SurrealDB v3.1.x, record-id
167
+ // matching with `IN` is broken — `SELECT id FROM <table> WHERE id IN
168
+ // [<recordid>]` returns NO rows even for an existing record (plain-field
169
+ // `IN` works; record-id `IN` does not). That made this check report EVERY
170
+ // removed id as gone and DELETE live local records (e.g. a freshly-created
171
+ // collection vanished mid-session). `SELECT id FROM $ids` matches correctly.
172
+ // (The old comment claimed `FROM $ids` returned Internal/0 on v3.0; it works
173
+ // on v3.1, and even if it ever errored the catch below skips deletion —
174
+ // strictly safer than the silent empty-result the `IN` form produced.)
165
175
  let existingRemoteIds: Set<string>;
166
176
  try {
167
- existingRemoteIds = new Set();
168
- for (const [table, ids] of byTable) {
169
- const [existing] = await this.remote.query<[{ id: RecordId }[]]>(
170
- 'SELECT id FROM type::table($table) WHERE id IN $ids',
171
- { table, ids }
172
- );
173
- for (const row of existing) {
174
- existingRemoteIds.add(encodeRecordId(row.id));
175
- }
176
- }
177
+ const [existing] = await this.remote.query<[{ id: RecordId }[]]>(
178
+ 'SELECT id FROM $ids',
179
+ { ids: removed }
180
+ );
181
+ existingRemoteIds = new Set(
182
+ (existing ?? []).map((row) => encodeRecordId(row.id))
183
+ );
177
184
  } catch (err) {
178
185
  // Verification failed. Skip deletion entirely — the next sync
179
186
  // round re-derives the diff and we get another shot. The
@@ -187,9 +194,14 @@ export class SyncEngine {
187
194
  },
188
195
  'Remote existence check failed, skipping deletion to avoid clobbering fresh data'
189
196
  );
190
- return;
197
+ return [];
191
198
  }
192
199
 
200
+ // Ids that left the view's list_ref but STILL exist upstream — not deletions,
201
+ // just a view-membership change (e.g. a record whose field changed so it no
202
+ // longer matches the query). The caller drops these from `localArray` so the
203
+ // poll's diff stops re-flagging them every tick (the `job:` churn).
204
+ const stillRemoteIds: string[] = [];
193
205
  for (const recordId of removed) {
194
206
  const recordIdStr = encodeRecordId(recordId);
195
207
  if (!existingRemoteIds.has(recordIdStr)) {
@@ -200,10 +212,12 @@ export class SyncEngine {
200
212
  },
201
213
  'Deleting confirmed removed record'
202
214
  );
203
-
204
215
  // Use CacheModule to handle both local DB and DBSP deletion
205
216
  await this.cache.delete(recordId.table.name, recordIdStr);
217
+ } else {
218
+ stillRemoteIds.push(recordIdStr);
206
219
  }
207
220
  }
221
+ return stillRemoteIds;
208
222
  }
209
223
  }