@spooky-sync/core 0.0.1-canary.161 → 0.0.1-canary.162

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.
@@ -44,7 +44,12 @@ import {
44
44
  } from '../../utils/index';
45
45
  import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
46
46
  import type { PushEventOptions } from '../../events/index';
47
- import { buildWindowMaterialization, buildWindowMaterializationPlan } from './window-query';
47
+ import {
48
+ buildIdSetPlan,
49
+ buildIdSetSurql,
50
+ buildWindowMaterialization,
51
+ buildWindowMaterializationPlan,
52
+ } from './window-query';
48
53
  import { mintMutationId } from './mutation-id';
49
54
 
50
55
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
@@ -236,7 +241,8 @@ export class DataModule<S extends SchemaStructure> {
236
241
  params,
237
242
  ttl,
238
243
  tableName,
239
- plan
244
+ plan,
245
+ await this.calculateMembershipKey({ surql: surqlString, params })
240
246
  );
241
247
  this.pendingQueries.set(hash, promise);
242
248
  try {
@@ -442,54 +448,144 @@ export class DataModule<S extends SchemaStructure> {
442
448
  await this.processStreamUpdate(pending);
443
449
  }
444
450
 
445
- // Materialize a query's result rows from the local DB. For a windowed query
446
- // (`LIMIT n START m`, m>0) the original surql is NOT re-run — re-applying
447
- // `START m` against the shared local store would skip the window's own rows
448
- // (sparse windowing) and return nothing. Instead we select the window's
449
- // record id-set directly, preferring the server's `list_ref` (`remoteArray`,
450
- // authoritative) over the in-browser SSP's view (`sspArray`), and re-apply
451
- // the original ORDER BY for stable order.
451
+ /**
452
+ * Materialize a query's result rows from the local store.
453
+ *
454
+ * A query's rows are its MEMBERSHIP — the id-set the server put in
455
+ * `_00_list_ref` (`remoteArray`) — not "every local body that matches the
456
+ * WHERE". Those two disagree, and the disagreement was the bug: when a row
457
+ * leaves a query's window but still exists upstream, `handleRemovedRecords`
458
+ * keeps its local body and never re-fetches it, so a predicate re-scan finds
459
+ * that stale body still matching and keeps rendering the row. Selecting the
460
+ * id-set directly is also the only correct thing for a windowed query, where
461
+ * re-applying `START m` against the shared local store skips the window's own
462
+ * rows entirely (sparse windowing) and returns nothing.
463
+ *
464
+ * The rendered set is:
465
+ *
466
+ * (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
467
+ *
468
+ * The middle term keeps optimistic writes visible without re-admitting stale
469
+ * rows. Every local write is fed to the SSP (`cache.saveBatch` →
470
+ * `ingestMany`), so `localArray` answers "does this row match the predicate
471
+ * per LOCAL truth". A pending write that moves a row into the window is in
472
+ * `localArray` and shows; one that moves a row out is absent and does not; a
473
+ * stale body the server dropped has no pending write at all, so it stays out.
474
+ * `pendingDeletes` covers the reverse lag — the server still lists a row whose
475
+ * DELETE is sitting in our outbox.
476
+ *
477
+ * Falls back to the predicate scan only when membership has never been
478
+ * established (a query first run on this device), so an offline first paint
479
+ * still shows something.
480
+ */
452
481
  private async materializeRecords(
453
482
  queryState: QueryState,
454
483
  sspArray?: Array<[string, number]>
455
484
  ): Promise<Record<string, any>[]> {
456
485
  const t0 = performance.now();
457
- const plan = queryState.config.plan;
458
- const windowMat = buildWindowMaterialization(queryState.config.surql);
486
+ const records = await this.materializeFromConfig(queryState.config, sspArray);
487
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
488
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
489
+ return records;
490
+ }
491
+
492
+ /**
493
+ * The materialization itself, without the DevTools timing wrapper. Split out so
494
+ * cold-start seeding can use it before a `QueryState` exists.
495
+ */
496
+ private async materializeFromConfig(
497
+ config: QueryConfig,
498
+ sspArray?: Array<[string, number]>
499
+ ): Promise<Record<string, any>[]> {
500
+ const plan = config.plan;
501
+ const membership = this.resolveMembership(config, sspArray);
459
502
  let records: Record<string, any>[];
460
- if (windowMat) {
461
- const win =
462
- (queryState.config.remoteArray?.length && queryState.config.remoteArray) ||
463
- (sspArray?.length && sspArray) ||
464
- queryState.config.localArray ||
465
- [];
466
- const winIds = win.map(([id]) => parseRecordIdString(id));
503
+
504
+ if (membership) {
505
+ const ids = await this.buildRenderIds(config, membership, sspArray);
467
506
  if (plan) {
468
- // Engine-neutral window materialization: select exactly the id-set,
469
- // keeping ORDER BY + relations. Works on any local engine.
470
- const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
471
- records = await this.local.select(winPlan, queryState.config.params);
507
+ records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
472
508
  } else {
473
- const [rows] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
474
- ...queryState.config.params,
475
- __win: winIds,
476
- });
477
- records = rows || [];
509
+ const idSetSurql = buildIdSetSurql(config.surql);
510
+ if (idSetSurql) {
511
+ const [rows] = await this.local.query<[Record<string, any>[]]>(idSetSurql.query, {
512
+ ...config.params,
513
+ __win: ids,
514
+ });
515
+ records = rows || [];
516
+ } else {
517
+ // Unparseable shape (no top-level FROM): the predicate scan is the
518
+ // only option left. Rare — the query-builder always supplies a plan.
519
+ const [rows] = await this.local.query<[Record<string, any>[]]>(
520
+ config.surql,
521
+ config.params
522
+ );
523
+ records = rows || [];
524
+ }
478
525
  }
479
526
  } else if (plan) {
480
- records = await this.local.select(plan, queryState.config.params);
527
+ records = await this.local.select(plan, config.params);
481
528
  } else {
482
- const [rows] = await this.local.query<[Record<string, any>[]]>(
483
- queryState.config.surql,
484
- queryState.config.params
485
- );
529
+ const [rows] = await this.local.query<[Record<string, any>[]]>(config.surql, config.params);
486
530
  records = rows || [];
487
531
  }
488
- // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
489
- this.recordPhase(queryState, 'localFetch', performance.now() - t0);
490
532
  return records;
491
533
  }
492
534
 
535
+ /**
536
+ * The authoritative membership list to render from, or `null` when membership
537
+ * has never been established and the caller must fall back to a scan.
538
+ *
539
+ * A windowed query has no usable fallback — re-running its `START m` locally
540
+ * returns the wrong rows — so it renders from whatever id-set is on hand
541
+ * (SSP's included) rather than degrading to a scan. That is the pre-existing
542
+ * behavior for windows and is preserved.
543
+ */
544
+ private resolveMembership(
545
+ config: QueryConfig,
546
+ sspArray?: Array<[string, number]>
547
+ ): RecordVersionArray | null {
548
+ if (config.membershipKnown) return config.remoteArray ?? [];
549
+ if (buildWindowMaterialization(config.surql) !== null) {
550
+ return (
551
+ (config.remoteArray?.length && config.remoteArray) ||
552
+ (sspArray?.length && sspArray) ||
553
+ config.localArray ||
554
+ []
555
+ );
556
+ }
557
+ return null;
558
+ }
559
+
560
+ /** Apply the pending-write union and pending-delete subtraction, and map to
561
+ * RecordIds for the engines' id-set path. */
562
+ private async buildRenderIds(
563
+ config: QueryConfig,
564
+ membership: RecordVersionArray,
565
+ sspArray?: Array<[string, number]>
566
+ ): Promise<unknown[]> {
567
+ const { writes, deletes } = await this.getPendingRecordIds();
568
+ const ordered: string[] = [];
569
+ const seen = new Set<string>();
570
+ for (const [id] of membership) {
571
+ if (deletes.has(id) || seen.has(id)) continue;
572
+ seen.add(id);
573
+ ordered.push(id);
574
+ }
575
+ if (writes.size > 0) {
576
+ // Only pending writes the SSP agrees currently match this query — see the
577
+ // formula in `materializeRecords`. `sspArray` is the fresher signal when a
578
+ // stream update triggered this pass; `localArray` is the persisted one.
579
+ const localView = (sspArray?.length && sspArray) || config.localArray || [];
580
+ for (const [id] of localView) {
581
+ if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
582
+ seen.add(id);
583
+ ordered.push(id);
584
+ }
585
+ }
586
+ return ordered.map((id) => parseRecordIdString(id));
587
+ }
588
+
493
589
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
494
590
  const { queryHash, localArray, materializationTimeMs } = update;
495
591
  const queryState = this.activeQueries.get(queryHash);
@@ -823,11 +919,16 @@ export class DataModule<S extends SchemaStructure> {
823
919
  if (epoch !== this.local.epoch) return;
824
920
 
825
921
  // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
826
- // prefers it for windowed queries (correct window) and it feeds the version
827
- // dedup. Registration later overwrites it with the authoritative `_00_list_ref`.
922
+ // renders from it and it feeds the version dedup. Registration later
923
+ // overwrites it with the authoritative `_00_list_ref`.
828
924
  queryState.config.remoteArray = rows.map(
829
925
  (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
830
926
  );
927
+ // These rows ARE the server's answer to this query, so membership is now
928
+ // known and rendering can stop falling back to a predicate scan. The durable
929
+ // `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
930
+ // single write point) — the registration that follows lands there anyway.
931
+ queryState.config.membershipKnown = true;
831
932
 
832
933
  queryState.records = await this.materializeRecords(queryState);
833
934
  const subscribers = this.subscriptions.get(hash);
@@ -917,6 +1018,88 @@ export class DataModule<S extends SchemaStructure> {
917
1018
  );
918
1019
  }
919
1020
 
1021
+ // ---- Durable membership (`_00_window`) ---------------------------------
1022
+ //
1023
+ // A query's authoritative membership is the id-set the server put in
1024
+ // `_00_list_ref` — `config.remoteArray`. It is persisted on the `_00_query`
1025
+ // row, but that row's id is salted with `session::id()`, which is new on every
1026
+ // page load (and `''` offline), and `doSwitchBucket` wipes the table outright.
1027
+ // So membership never survived a reload: the first paint fell back to a
1028
+ // predicate scan over whatever bodies were still cached, which re-included
1029
+ // rows the server had already dropped from the window.
1030
+ //
1031
+ // `_00_window` fixes that: same data, keyed by a session-independent hash, in
1032
+ // a table nothing wipes. Mirrors the durable `_00_preload` marker above.
1033
+
1034
+ /**
1035
+ * Read the durable membership row, or `null` if this query has never had
1036
+ * authoritative membership on this device. Any read error is treated as
1037
+ * "unknown" so a broken row degrades to the predicate scan rather than
1038
+ * rendering an empty list.
1039
+ */
1040
+ async getWindowMembership(key: string): Promise<RecordVersionArray | null> {
1041
+ try {
1042
+ const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
1043
+ if (!row || typeof row !== 'object') return null;
1044
+ const ids = (row as any).ids;
1045
+ return Array.isArray(ids) ? (ids as RecordVersionArray) : null;
1046
+ } catch {
1047
+ return null;
1048
+ }
1049
+ }
1050
+
1051
+ /** Persist the durable membership row. Best-effort: callers must not fail a
1052
+ * sync round because the mirror write failed. */
1053
+ async writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void> {
1054
+ try {
1055
+ await this.local.upsert(
1056
+ '_00_window',
1057
+ new RecordId('_00_window', key),
1058
+ { ids, updatedAt: Date.now() },
1059
+ 'replace'
1060
+ );
1061
+ } catch (err) {
1062
+ this.logger.debug(
1063
+ { err, key, Category: 'sp00ky-client::DataModule::writeWindowMembership' },
1064
+ 'Failed to persist window membership; it will be re-derived on next register'
1065
+ );
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Record ids with a mutation still in the outbox, split by direction.
1071
+ *
1072
+ * Both halves feed {@link materializeRecords}: `writes` keeps optimistic
1073
+ * creates/updates visible before the server has acknowledged them, and
1074
+ * `deletes` suppresses rows the server still lists because our DELETE hasn't
1075
+ * been processed yet. Reading `_00_pending_mutations` (rather than tracking
1076
+ * ids in memory) is what makes both survive a reload.
1077
+ *
1078
+ * On failure returns empty sets: membership alone then decides, which can
1079
+ * briefly hide an optimistic write but never resurrects a deleted row.
1080
+ */
1081
+ async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
1082
+ const writes = new Set<string>();
1083
+ const deletes = new Set<string>();
1084
+ try {
1085
+ const [rows] = await this.local.query<
1086
+ [{ recordId: RecordId<string>; mutationType: string }[]]
1087
+ >('SELECT recordId, mutationType FROM _00_pending_mutations');
1088
+ for (const row of rows ?? []) {
1089
+ if (!row?.recordId) continue;
1090
+ const id = encodeRecordId(row.recordId);
1091
+ if (row.mutationType === 'delete') deletes.add(id);
1092
+ else writes.add(id);
1093
+ }
1094
+ } catch (err) {
1095
+ this.logger.warn(
1096
+ { err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
1097
+ 'Failed to read pending mutations; optimistic writes may be briefly hidden'
1098
+ );
1099
+ }
1100
+ return { writes, deletes };
1101
+ }
1102
+
920
1103
  /** True while ≥1 live subscriber is watching this query (refcount guard). */
921
1104
  hasSubscribers(hash: string): boolean {
922
1105
  return (this.subscriptions.get(hash)?.size ?? 0) > 0;
@@ -1017,6 +1200,13 @@ export class DataModule<S extends SchemaStructure> {
1017
1200
  }
1018
1201
  const epoch = this.local.epoch;
1019
1202
  queryState.config.remoteArray = remoteArray;
1203
+ // The single point where authoritative membership arrives (registration and
1204
+ // the `_00_list_ref` poll both land here), so it is where "we now know the
1205
+ // membership" is latched and where the durable mirror is written.
1206
+ queryState.config.membershipKnown = true;
1207
+ if (queryState.config.membershipKey) {
1208
+ await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
1209
+ }
1020
1210
  try {
1021
1211
  await this.local.query(
1022
1212
  surql.seal(surql.updateSet('id', ['remoteArray'])),
@@ -1074,6 +1264,18 @@ export class DataModule<S extends SchemaStructure> {
1074
1264
  config.localArray = [];
1075
1265
  config.remoteArray = [];
1076
1266
  config.subqueryRemoteArray = undefined;
1267
+ // Membership belongs to the bucket we just left. Re-seed from the NEW
1268
+ // bucket's durable row if it has one (a returning user), otherwise mark it
1269
+ // unknown so the first paint falls back to a scan instead of rendering an
1270
+ // empty list until the server answers.
1271
+ config.membershipKnown = false;
1272
+ if (config.membershipKey) {
1273
+ const durable = await this.getWindowMembership(config.membershipKey);
1274
+ if (durable) {
1275
+ config.remoteArray = durable;
1276
+ config.membershipKnown = true;
1277
+ }
1278
+ }
1077
1279
  queryState.hydrated = false;
1078
1280
  queryState.syncNotified = false;
1079
1281
  queryState.records = [];
@@ -1593,7 +1795,8 @@ export class DataModule<S extends SchemaStructure> {
1593
1795
  params: Record<string, any>,
1594
1796
  ttl: QueryTimeToLive,
1595
1797
  tableName: T,
1596
- plan?: QueryPlan
1798
+ plan?: QueryPlan,
1799
+ membershipKey?: string
1597
1800
  ): Promise<QueryHash> {
1598
1801
  const queryState = await this.createNewQuery<T>({
1599
1802
  recordId,
@@ -1602,6 +1805,7 @@ export class DataModule<S extends SchemaStructure> {
1602
1805
  ttl,
1603
1806
  tableName,
1604
1807
  plan,
1808
+ membershipKey,
1605
1809
  });
1606
1810
 
1607
1811
  const t0 = performance.now();
@@ -1685,6 +1889,7 @@ export class DataModule<S extends SchemaStructure> {
1685
1889
  ttl,
1686
1890
  tableName,
1687
1891
  plan,
1892
+ membershipKey,
1688
1893
  }: {
1689
1894
  recordId: RecordId;
1690
1895
  surql: string;
@@ -1692,6 +1897,7 @@ export class DataModule<S extends SchemaStructure> {
1692
1897
  ttl: QueryTimeToLive;
1693
1898
  tableName: T;
1694
1899
  plan?: QueryPlan;
1900
+ membershipKey?: string;
1695
1901
  }): Promise<QueryState> {
1696
1902
  // `_00_*` meta tables (feature flags, app releases) are framework-owned:
1697
1903
  // they exist in the client db schema by construction but are never part of
@@ -1742,15 +1948,44 @@ export class DataModule<S extends SchemaStructure> {
1742
1948
  // engines materialize via `select(plan)` instead of parsing `surql`.
1743
1949
  plan,
1744
1950
  params: parseParams(tableSchema.columns, configRecord.params),
1951
+ membershipKey,
1745
1952
  };
1746
1953
 
1954
+ // The `_00_query` row we just read is session-salted, so on a reload it is
1955
+ // always a fresh row with empty arrays. Recover the last authoritative
1956
+ // membership from the durable `_00_window` row instead — this is what stops a
1957
+ // removed row reappearing after a reload, and it works with no network.
1958
+ if (membershipKey && !config.remoteArray?.length) {
1959
+ const durable = await this.getWindowMembership(membershipKey);
1960
+ if (durable) {
1961
+ config.remoteArray = durable;
1962
+ config.membershipKnown = true;
1963
+ }
1964
+ } else if (config.remoteArray?.length) {
1965
+ config.membershipKnown = true;
1966
+ }
1967
+
1747
1968
  let records: Record<string, any>[] = [];
1748
1969
  // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
1749
1970
  // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
1750
1971
  // skips m rows on every window open — AND returns the wrong rows for sparse
1751
1972
  // windows (the reason `buildWindowMaterialization` exists). Those windows are
1752
1973
  // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
1753
- if (buildWindowMaterialization(surqlString) === null) {
1974
+ //
1975
+ // With known membership the same reasoning applies to EVERY query, windowed
1976
+ // or not: the predicate scan below would re-admit rows the server has already
1977
+ // dropped from the window (their bodies are still cached and still match the
1978
+ // WHERE). Seed from membership instead.
1979
+ if (config.membershipKnown) {
1980
+ try {
1981
+ records = await this.materializeFromConfig(config);
1982
+ } catch (err) {
1983
+ this.logger.warn(
1984
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
1985
+ 'Failed to seed records from durable membership'
1986
+ );
1987
+ }
1988
+ } else if (buildWindowMaterialization(surqlString) === null) {
1754
1989
  try {
1755
1990
  // Prefer the engine-neutral plan (required for non-SurrealQL engines);
1756
1991
  // fall back to running the raw surql on the SurrealDB engine.
@@ -1800,7 +2035,24 @@ export class DataModule<S extends SchemaStructure> {
1800
2035
  // sessionId is part of the hash so the same logical query from two
1801
2036
  // sessions (e.g. two browser tabs of the same user) lands on different
1802
2037
  // `_00_query` rows and doesn't fight over a shared one.
1803
- const content = JSON.stringify({ ...data, sessionId: this.sessionId });
2038
+ return this.sha256(JSON.stringify({ ...data, sessionId: this.sessionId }));
2039
+ }
2040
+
2041
+ /**
2042
+ * Session-independent counterpart of {@link calculateHash}: the key for a
2043
+ * query's durable `_00_window` membership row.
2044
+ *
2045
+ * Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
2046
+ * can never drift apart. The salt is right for `_00_query` (two tabs must not
2047
+ * fight over one row) and wrong for membership, which has to be recognizable
2048
+ * after a reload — a reload mints a new session id, and offline the salt is
2049
+ * `''`, so a salted key can never match what the previous session wrote.
2050
+ */
2051
+ private async calculateMembershipKey(data: any): Promise<string> {
2052
+ return this.sha256(JSON.stringify(data));
2053
+ }
2054
+
2055
+ private async sha256(content: string): Promise<string> {
1804
2056
  const msgBuffer = new TextEncoder().encode(content);
1805
2057
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
1806
2058
  const hashArray = Array.from(new Uint8Array(hashBuffer));
@@ -38,6 +38,58 @@ export function buildWindowMaterializationPlan(
38
38
  ids: unknown[]
39
39
  ): QueryPlan | null {
40
40
  if (plan.offset === undefined || plan.offset <= 0) return null;
41
+ return buildIdSetPlan(plan, ids);
42
+ }
43
+
44
+ /**
45
+ * Raw-SurrealQL counterpart of {@link buildIdSetPlan}, for queries that arrived
46
+ * without a plan. Same rewrite as {@link buildWindowMaterialization} without the
47
+ * offset guard: keep the projection and top-level `ORDER BY`, replace the source
48
+ * with the bound id-set.
49
+ */
50
+ export function buildIdSetSurql(
51
+ surql: string,
52
+ idsParam = '__win'
53
+ ): { query: string } | null {
54
+ const kw = scanTopLevelClauses(surql);
55
+ if (kw.fromIndex === null) return null;
56
+ return { query: renderIdSetSelect(surql, kw, idsParam) };
57
+ }
58
+
59
+ /** Shared body of the two id-set surql rewrites: keep the projection and the
60
+ * top-level `ORDER BY`, replace the source with the bound id-set. */
61
+ function renderIdSetSelect(surql: string, kw: TopLevelClauses, idsParam: string): string {
62
+ const selectClause = surql.slice(0, kw.fromIndex!).trimEnd(); // "SELECT <projection>"
63
+
64
+ let orderBy = '';
65
+ const orderByIndex = kw.orderByIndex;
66
+ if (orderByIndex !== null) {
67
+ const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
68
+ (n): n is number => n !== null && n > orderByIndex
69
+ );
70
+ orderBy = ' ' + surql.slice(orderByIndex, Math.min(...ends)).trim();
71
+ }
72
+
73
+ return `${selectClause} FROM $${idsParam}${orderBy}`;
74
+ }
75
+
76
+ /**
77
+ * Restrict any plan's base rows to an explicit id-set, regardless of offset.
78
+ *
79
+ * Same rewrite as {@link buildWindowMaterializationPlan} without the offset
80
+ * guard, for rendering a query from its authoritative membership list rather
81
+ * than by re-running its predicate.
82
+ *
83
+ * `where` is dropped deliberately: the id-set already IS the answer to the
84
+ * predicate as the server evaluated it, and re-applying the `WHERE` locally is
85
+ * exactly the bug — a row that left the window keeps its (stale, never
86
+ * re-fetched) local body, so it keeps matching and keeps rendering.
87
+ * `limit`/`offset` go for the same reason: both are already baked into which ids
88
+ * are in the set, so the set itself is the bound. (Both engines ignore them on
89
+ * the `ids` path regardless — only `select` and `orderBy` are honored there.)
90
+ * `orderBy` is kept so display order stays deterministic.
91
+ */
92
+ export function buildIdSetPlan(plan: QueryPlan, ids: unknown[]): QueryPlan {
41
93
  return {
42
94
  ...plan,
43
95
  ids,
@@ -54,19 +106,7 @@ export function buildWindowMaterialization(
54
106
  const kw = scanTopLevelClauses(surql);
55
107
  if (kw.startValue === null || kw.startValue <= 0) return null;
56
108
  if (kw.fromIndex === null) return null;
57
-
58
- const selectClause = surql.slice(0, kw.fromIndex).trimEnd(); // "SELECT <projection>"
59
-
60
- let orderBy = '';
61
- if (kw.orderByIndex !== null) {
62
- const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
63
- (n): n is number => n !== null && n > kw.orderByIndex!
64
- );
65
- const end = Math.min(...ends);
66
- orderBy = ' ' + surql.slice(kw.orderByIndex, end).trim();
67
- }
68
-
69
- return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
109
+ return { query: renderIdSetSelect(surql, kw, idsParam) };
70
110
  }
71
111
 
72
112
  interface TopLevelClauses {
@@ -0,0 +1,134 @@
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
+ getQueryById: vi.fn().mockReturnValue(queryState),
48
+ getQueryByHash: vi.fn().mockReturnValue(queryState),
49
+ updateQueryRemoteArray,
50
+ notifyQuerySynced,
51
+ getActiveQueryHashes: () => ['h1'],
52
+ getPendingRecordIds: async () => ({ writes: new Set(), deletes: new Set() }),
53
+ };
54
+
55
+ const sync = new Sp00kySync(
56
+ {} as any,
57
+ { query: vi.fn() } as any,
58
+ {} as any,
59
+ dataModule,
60
+ {} as any,
61
+ logger
62
+ );
63
+ // `runSyncForQuery` drives the engine + scheduler; both are out of scope here.
64
+ (sync as any).runSyncForQuery = vi.fn().mockResolvedValue(undefined);
65
+
66
+ const live = (action: 'CREATE' | 'UPDATE' | 'DELETE', id: string, version: number) =>
67
+ (sync as any).handleRemoteListRefChange(
68
+ action,
69
+ queryId,
70
+ new RecordId('thread', id),
71
+ version
72
+ ) as Promise<void>;
73
+
74
+ return { sync, queryState, updateQueryRemoteArray, notifyQuerySynced, live };
75
+ }
76
+
77
+ describe('LIVE list_ref removal → membership', () => {
78
+ beforeEach(() => vi.clearAllMocks());
79
+
80
+ it('drops the removed id from remoteArray immediately', async () => {
81
+ const { queryState, updateQueryRemoteArray, live } = makeSync();
82
+
83
+ await live('DELETE', 'b', 2);
84
+
85
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [['thread:a', 1]]);
86
+ expect(queryState.config.remoteArray).toEqual([['thread:a', 1]]);
87
+ });
88
+
89
+ it('forces a re-render for a removal-only diff', async () => {
90
+ const { notifyQuerySynced, live } = makeSync();
91
+ await live('DELETE', 'b', 2);
92
+ expect(notifyQuerySynced).toHaveBeenCalledWith('h1');
93
+ });
94
+
95
+ it('does not force a re-render when rows were added (the stream update covers it)', async () => {
96
+ const { notifyQuerySynced, live } = makeSync();
97
+ await live('CREATE', 'c', 1);
98
+ expect(notifyQuerySynced).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it('adds a newly-arrived id to membership', async () => {
102
+ const { updateQueryRemoteArray, live } = makeSync();
103
+
104
+ await live('CREATE', 'c', 1);
105
+
106
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
107
+ ['thread:a', 1],
108
+ ['thread:b', 1],
109
+ ['thread:c', 1],
110
+ ]);
111
+ });
112
+
113
+ it('leaves membership alone while it is still unknown', async () => {
114
+ // Nothing authoritative has arrived yet, so there is no list to amend —
115
+ // registration will supply the whole thing shortly.
116
+ const { updateQueryRemoteArray, live } = makeSync({ membershipKnown: false });
117
+ await live('DELETE', 'b', 2);
118
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('is a no-op for an unknown query', async () => {
122
+ const { sync, updateQueryRemoteArray } = makeSync();
123
+ (sync as any).dataModule.getQueryById = vi.fn().mockReturnValue(undefined);
124
+
125
+ await (sync as any).handleRemoteListRefChange(
126
+ 'DELETE',
127
+ new RecordId('_00_query', 'other'),
128
+ new RecordId('thread', 'b'),
129
+ 2
130
+ );
131
+
132
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
133
+ });
134
+ });