@spooky-sync/core 0.0.1-canary.174 → 0.0.1-canary.176

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.
package/dist/index.d.ts CHANGED
@@ -848,7 +848,10 @@ declare class DataModule<S extends SchemaStructure> {
848
848
  getActiveQueries(): QueryState[];
849
849
  getActiveQueryHashes(): QueryHash[];
850
850
  updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void>;
851
- updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void>;
851
+ updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray, opts?: {
852
+ /** `_00_query.rowCount` read in the same round trip; `null` = unknown. */
853
+ serverRowCount?: number | null;
854
+ }): Promise<void>;
852
855
  /**
853
856
  * Cancel every armed timer ahead of a local-bucket switch: stream-update
854
857
  * debounce timers (their pending updates carry the OLD bucket's id-sets) and
package/dist/index.js CHANGED
@@ -2624,6 +2624,7 @@ var SqliteCacheEngine = class {
2624
2624
  let sql = `SELECT data FROM "${plan.table}"`;
2625
2625
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
2626
2626
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
2627
+ else sql += ` ORDER BY id`;
2627
2628
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
2628
2629
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
2629
2630
  const rows = await this.execRows(sql, bind);
@@ -3565,6 +3566,9 @@ var DataModule = class {
3565
3566
  ordered.push(id);
3566
3567
  }
3567
3568
  }
3569
+ const hasExplicitOrder = (config.plan?.orderBy?.length ?? 0) > 0;
3570
+ const isWindow = buildWindowMaterialization(config.surql) !== null;
3571
+ if (!hasExplicitOrder && !isWindow) ordered.sort();
3568
3572
  return ordered.map((id) => parseRecordIdString(id));
3569
3573
  }
3570
3574
  async processStreamUpdate(update) {
@@ -4009,7 +4013,7 @@ var DataModule = class {
4009
4013
  throw err;
4010
4014
  }
4011
4015
  }
4012
- async updateQueryRemoteArray(hash, remoteArray) {
4016
+ async updateQueryRemoteArray(hash, remoteArray, opts) {
4013
4017
  const queryState = this.getQueryByHash(hash);
4014
4018
  if (!queryState) {
4015
4019
  this.logger.warn({
@@ -4019,15 +4023,19 @@ var DataModule = class {
4019
4023
  return;
4020
4024
  }
4021
4025
  if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
4022
- const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
4023
- queryState.config.emptyReads = emptyReads;
4024
- if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
4025
- this.logger.debug({
4026
- hash,
4027
- emptyReads,
4028
- Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4029
- }, "Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)");
4030
- return;
4026
+ const serverRowCount = opts?.serverRowCount;
4027
+ if (!(serverRowCount === 0)) {
4028
+ const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
4029
+ queryState.config.emptyReads = emptyReads;
4030
+ if (!(serverRowCount === null || serverRowCount === void 0 ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS : false)) {
4031
+ this.logger.debug({
4032
+ hash,
4033
+ emptyReads,
4034
+ serverRowCount,
4035
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
4036
+ }, "Ignoring empty membership: the server still reports rows for this query");
4037
+ return;
4038
+ }
4031
4039
  }
4032
4040
  }
4033
4041
  const epoch = this.local.epoch;
@@ -5107,6 +5115,24 @@ function buildListRefSelect(table) {
5107
5115
  return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
5108
5116
  }
5109
5117
  /**
5118
+ * Build the select that says whether an EMPTY id-set means "this query has no
5119
+ * rows" or "the server has not published them yet".
5120
+ *
5121
+ * The SSP writes `rowCount` onto the `_00_query` row in the same statement that
5122
+ * registers the view — synchronously, and BEFORE it hands the view's initial
5123
+ * edges to the coalescing edge flusher. So the two are not interchangeable:
5124
+ * `rowCount > 0` with no edges is the flush window, and only `rowCount === 0`
5125
+ * is a genuinely empty query. Polling the edges alone cannot tell those apart
5126
+ * no matter how long it waits, which is why this is read alongside them rather
5127
+ * than a retry counter.
5128
+ *
5129
+ * Returns `NONE` (→ null) when the row is not readable or does not exist yet;
5130
+ * callers must treat that as "unknown", not as zero.
5131
+ */
5132
+ function buildQueryRowCountSelect() {
5133
+ return "SELECT VALUE rowCount FROM ONLY $in";
5134
+ }
5135
+ /**
5110
5136
  * Build the SurrealQL select for a query's SUBQUERY child edges — the
5111
5137
  * mirror of {@link buildListRefSelect}. `.related()` queries register a
5112
5138
  * correlated subquery; the SSP materializes each matched child as a
@@ -5978,14 +6004,14 @@ var Sp00kySync = class Sp00kySync {
5978
6004
  const queryState = this.dataModule.getQueryByHash(queryHash);
5979
6005
  if (!queryState) return false;
5980
6006
  const listRefTbl = this.listRefTable();
5981
- const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
6007
+ const [items, serverRowCount] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
5982
6008
  if (!Array.isArray(items)) return false;
5983
6009
  const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
5984
6010
  const prevRemote = queryState.config.remoteArray ?? [];
5985
6011
  const freshIds = new Set(fresh.map(([id]) => id));
5986
6012
  const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
5987
6013
  const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
5988
- if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
6014
+ if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { serverRowCount });
5989
6015
  try {
5990
6016
  await this.syncQuery(queryHash);
5991
6017
  } catch (err) {
@@ -6467,7 +6493,7 @@ var Sp00kySync = class Sp00kySync {
6467
6493
  ttl: queryState.config.ttl
6468
6494
  } });
6469
6495
  const listRefTbl = this.listRefTable();
6470
- const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
6496
+ const [items, serverRowCount] = await this.remote.query(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, { in: queryState.config.id });
6471
6497
  this.logger.trace({
6472
6498
  queryId: encodeRecordId(queryState.config.id),
6473
6499
  items,
@@ -6479,7 +6505,7 @@ var Sp00kySync = class Sp00kySync {
6479
6505
  array,
6480
6506
  Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
6481
6507
  }, "createdRemoteQuery");
6482
- if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
6508
+ if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
6483
6509
  await this.syncSubqueryChildren(queryHash).catch((err) => {
6484
6510
  this.logger.info({
6485
6511
  err: err?.message ?? err,
@@ -6866,8 +6892,8 @@ function selfAllowlistedVariant(flag, userId) {
6866
6892
 
6867
6893
  //#endregion
6868
6894
  //#region src/modules/devtools/index.ts
6869
- const CORE_VERSION = "0.0.1-canary.174";
6870
- const WASM_VERSION = "0.0.1-canary.174";
6895
+ const CORE_VERSION = "0.0.1-canary.176";
6896
+ const WASM_VERSION = "0.0.1-canary.176";
6871
6897
  const SURREAL_VERSION = "3.0.3";
6872
6898
  var DevToolsService = class DevToolsService {
6873
6899
  eventsHistory = [];
@@ -11298,7 +11324,7 @@ var Sp00kyClient = class {
11298
11324
  return new TabsCoordinator({
11299
11325
  tabId,
11300
11326
  fingerprint: computeTabsFingerprint({
11301
- coreVersion: "0.0.1-canary.174",
11327
+ coreVersion: "0.0.1-canary.176",
11302
11328
  schemaHash: hash53(this.config.schemaSurql),
11303
11329
  endpoint: this.config.database.endpoint ?? "",
11304
11330
  namespace: this.config.database.namespace,
@@ -60,6 +60,7 @@ async function executeSelect(plan, params, db) {
60
60
  let sql = `SELECT data FROM "${plan.table}"`;
61
61
  if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
62
62
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
63
+ else sql += ` ORDER BY id`;
63
64
  if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
64
65
  if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
65
66
  const rows = execRows(db, sql, bind);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.174",
3
+ "version": "0.0.1-canary.176",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.174",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.174",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.176",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.176",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -229,6 +229,53 @@ describe('membership-authoritative rendering', () => {
229
229
  });
230
230
  });
231
231
 
232
+ describe('render order', () => {
233
+ it('sorts an unordered query so both paints agree', async () => {
234
+ // `_00_list_ref` is selected without an ORDER BY, so membership arrives
235
+ // shuffled. The first paint came from the local scan in id order, so
236
+ // rendering server order here is what made lists visibly reorder about a
237
+ // second after load.
238
+ const { dm, hash, state } = setup();
239
+ state.config.plan = { table: 'thread', where: [['done', '=', false]] } as any;
240
+
241
+ const ids = await (dm as any).buildRenderIds(state.config, [
242
+ ['thread:c', 1],
243
+ ['thread:a', 1],
244
+ ['thread:b', 1],
245
+ ]);
246
+
247
+ expect(ids.map(String)).toEqual(['thread:a', 'thread:b', 'thread:c']);
248
+ });
249
+
250
+ it('leaves an explicitly ordered query to the engine', async () => {
251
+ const { dm, state } = setup();
252
+ state.config.plan = { table: 'thread', orderBy: [['created', 'desc']] } as any;
253
+
254
+ const ids = await (dm as any).buildRenderIds(state.config, [
255
+ ['thread:c', 1],
256
+ ['thread:a', 1],
257
+ ]);
258
+
259
+ // Untouched: the engine applies the ORDER BY over the id set.
260
+ expect(ids.map(String)).toEqual(['thread:c', 'thread:a']);
261
+ });
262
+
263
+ it('preserves the slice order of a windowed query', async () => {
264
+ // For a window the id-set order IS the window: sorting it would reorder
265
+ // rows within the page.
266
+ const { dm, state } = setup();
267
+ state.config.surql = 'SELECT * FROM thread LIMIT 50 START 100;';
268
+ state.config.plan = { table: 'thread' } as any;
269
+
270
+ const ids = await (dm as any).buildRenderIds(state.config, [
271
+ ['thread:c', 1],
272
+ ['thread:a', 1],
273
+ ]);
274
+
275
+ expect(ids.map(String)).toEqual(['thread:c', 'thread:a']);
276
+ });
277
+ });
278
+
232
279
  describe('durability across a reload', () => {
233
280
  it('updateQueryRemoteArray latches membership and writes _00_window', async () => {
234
281
  const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
@@ -326,6 +373,57 @@ describe('membership-authoritative rendering', () => {
326
373
  expect(state.config.remoteArray).toEqual([]);
327
374
  });
328
375
 
376
+ it('ignores an empty id-set while the server still reports rows', async () => {
377
+ // The reported failure: registration returns before the SSP flushes the
378
+ // view's edges, and the poll 500ms later is still inside that window. A
379
+ // retry counter believes the second read and blanks the list ~2s after
380
+ // load; `rowCount` says the query has 26 rows, so no number of empty
381
+ // reads should ever be taken as "empty".
382
+ const { dm, state, local, hash } = setup({
383
+ membershipKey: 'stable-key',
384
+ remoteArray: [['thread:a', 1]],
385
+ membershipKnown: true,
386
+ });
387
+
388
+ for (let i = 0; i < 5; i++) {
389
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 26 });
390
+ }
391
+
392
+ expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
393
+ expect(local.windowRows.has('stable-key')).toBe(false);
394
+ });
395
+
396
+ it('believes an empty id-set the moment the server reports zero rows', async () => {
397
+ // The other half: a genuinely empty query must not sit on a stale seed
398
+ // waiting for a retry budget to run out.
399
+ const { dm, state, hash } = setup({
400
+ membershipKey: 'stable-key',
401
+ remoteArray: [['thread:a', 1]],
402
+ membershipKnown: true,
403
+ });
404
+
405
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
406
+
407
+ expect(state.config.membershipKnown).toBe(true);
408
+ expect(state.config.remoteArray).toEqual([]);
409
+ });
410
+
411
+ it('falls back to the retry budget when the row count is unreadable', async () => {
412
+ // Older servers, or a row the client cannot select: unknown must not
413
+ // strand the device on its durable seed forever.
414
+ const { dm, state, hash } = setup({
415
+ membershipKey: 'stable-key',
416
+ remoteArray: [['thread:a', 1]],
417
+ membershipKnown: true,
418
+ });
419
+
420
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
421
+ expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
422
+
423
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
424
+ expect(state.config.remoteArray).toEqual([]);
425
+ });
426
+
329
427
  it('does not seed membership from an empty durable row', async () => {
330
428
  // Self-heals devices poisoned before the guard existed: an empty durable
331
429
  // row is indistinguishable from "never had membership", so it must fall
@@ -591,6 +591,18 @@ export class DataModule<S extends SchemaStructure> {
591
591
  ordered.push(id);
592
592
  }
593
593
  }
594
+ // `_00_list_ref` is selected without an ORDER BY, so this id-set arrives in
595
+ // whatever order the server happened to return. For a query with its own
596
+ // ORDER BY that does not matter (the engine sorts), and for a window the
597
+ // id-set order IS the window's slice order and must be preserved. Anything
598
+ // else renders in server order while its first paint came from the local
599
+ // scan in id order — the same rows, visibly reshuffled a second later.
600
+ // Sorting here is what makes the two paints agree.
601
+ const hasExplicitOrder = (config.plan?.orderBy?.length ?? 0) > 0;
602
+ const isWindow = buildWindowMaterialization(config.surql) !== null;
603
+ if (!hasExplicitOrder && !isWindow) {
604
+ ordered.sort();
605
+ }
594
606
  return ordered.map((id) => parseRecordIdString(id));
595
607
  }
596
608
 
@@ -1197,7 +1209,14 @@ export class DataModule<S extends SchemaStructure> {
1197
1209
  }
1198
1210
  }
1199
1211
 
1200
- async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
1212
+ async updateQueryRemoteArray(
1213
+ hash: string,
1214
+ remoteArray: RecordVersionArray,
1215
+ opts?: {
1216
+ /** `_00_query.rowCount` read in the same round trip; `null` = unknown. */
1217
+ serverRowCount?: number | null;
1218
+ }
1219
+ ): Promise<void> {
1201
1220
  const queryState = this.getQueryByHash(hash);
1202
1221
  if (!queryState) {
1203
1222
  this.logger.warn(
@@ -1220,20 +1239,40 @@ export class DataModule<S extends SchemaStructure> {
1220
1239
  // row, and overwriting that with `[]` would blank the very rows the seed
1221
1240
  // exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
1222
1241
  // delivers the real set.
1223
- // Bounded, though: a query seeded from the durable row on a cold start has
1224
- // `remoteSeen === false`, and if its window really did empty while this
1225
- // device was away, the server will keep answering `[]`. Believe it on the
1226
- // second such read one poll cycle (~500ms) past the flush window — so
1227
- // stale rows can't render forever.
1242
+ // `serverRowCount` is what makes the two cases separable. The SSP writes it
1243
+ // onto the `_00_query` row in the same statement that registers the view,
1244
+ // before it queues the view's initial edges so `> 0` with no edges is the
1245
+ // flush window and `=== 0` is a genuinely empty query. `null`/undefined
1246
+ // means we could not read it (older server, row not visible yet).
1247
+ //
1248
+ // Retry counting cannot substitute for this: the poll runs 500ms after
1249
+ // registration, well inside the flush window for a real collection, so
1250
+ // "believe it the second time" blanked exactly the lists this guard exists
1251
+ // to protect — reported as rows vanishing ~2s after a page load.
1228
1252
  if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
1229
- const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
1230
- queryState.config.emptyReads = emptyReads;
1231
- if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
1232
- this.logger.debug(
1233
- { hash, emptyReads, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1234
- 'Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)'
1235
- );
1236
- return;
1253
+ const serverRowCount = opts?.serverRowCount;
1254
+ const knownEmpty = serverRowCount === 0;
1255
+ if (!knownEmpty) {
1256
+ // Unknown row count still gets a bounded escape hatch, so a server that
1257
+ // cannot report one never strands a device on a durable seed forever.
1258
+ const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
1259
+ queryState.config.emptyReads = emptyReads;
1260
+ const exhausted =
1261
+ serverRowCount === null || serverRowCount === undefined
1262
+ ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS
1263
+ : false; // a positive row count is never "confirmed empty"
1264
+ if (!exhausted) {
1265
+ this.logger.debug(
1266
+ {
1267
+ hash,
1268
+ emptyReads,
1269
+ serverRowCount,
1270
+ Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
1271
+ },
1272
+ 'Ignoring empty membership: the server still reports rows for this query'
1273
+ );
1274
+ return;
1275
+ }
1237
1276
  }
1238
1277
  }
1239
1278
 
@@ -19,6 +19,7 @@ import {
19
19
  applyRecordVersionDiff,
20
20
  ArraySyncer,
21
21
  buildListRefSelect,
22
+ buildQueryRowCountSelect,
22
23
  buildSubqueryListRefSelect,
23
24
  createDiffFromDbOp,
24
25
  diffRecordVersionArray,
@@ -853,10 +854,11 @@ export class Sp00kySync<S extends SchemaStructure> {
853
854
  const queryState = this.dataModule.getQueryByHash(queryHash);
854
855
  if (!queryState) return false;
855
856
  const listRefTbl = this.listRefTable();
856
- const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
857
- buildListRefSelect(listRefTbl),
858
- { in: queryState.config.id }
859
- );
857
+ const [items, serverRowCount] = await this.remote.query<
858
+ [{ out: RecordId<string>; version: number }[], number | null]
859
+ >(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
860
+ in: queryState.config.id,
861
+ });
860
862
  if (!Array.isArray(items)) return false;
861
863
  const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
862
864
  // Capture which ids LEFT the query's window (present in the cached
@@ -881,7 +883,7 @@ export class Sp00kySync<S extends SchemaStructure> {
881
883
  // and notifies subscribers. We skip an explicit `notifyQuerySynced`
882
884
  // because that path races the stream-update path (can notify with stale
883
885
  // records).
884
- await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
886
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { serverRowCount });
885
887
  }
886
888
  // Run `syncQuery` every tick regardless: it's a no-op when localArray has
887
889
  // caught up to remoteArray (`if (!diff) return`, issues no query), but it
@@ -1636,12 +1638,14 @@ export class Sp00kySync<S extends SchemaStructure> {
1636
1638
  // sync. `parent IS NONE` excludes subquery entries; the
1637
1639
  // `localArray` cache only tracks primary records.
1638
1640
  const listRefTbl = this.listRefTable();
1639
- const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
1640
- buildListRefSelect(listRefTbl),
1641
- {
1642
- in: queryState.config.id,
1643
- }
1644
- );
1641
+ // `rowCount` rides along: it is written by the SSP in the same statement
1642
+ // that registers the view, BEFORE the edges are flushed, so it is the only
1643
+ // way to tell "this query is empty" from "its edges have not landed yet".
1644
+ const [items, serverRowCount] = await this.remote.query<
1645
+ [{ out: RecordId<string>; version: number }[], number | null]
1646
+ >(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
1647
+ in: queryState.config.id,
1648
+ });
1645
1649
 
1646
1650
  this.logger.trace(
1647
1651
  {
@@ -1665,7 +1669,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1665
1669
 
1666
1670
  if (array) {
1667
1671
  /// Incantation existed already
1668
- await this.dataModule.updateQueryRemoteArray(queryHash, array);
1672
+ await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
1669
1673
  }
1670
1674
 
1671
1675
  // Pull the bodies of any `.related()` subquery children into the local
@@ -188,6 +188,25 @@ export function buildListRefSelect(table: string): string {
188
188
  return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
189
189
  }
190
190
 
191
+ /**
192
+ * Build the select that says whether an EMPTY id-set means "this query has no
193
+ * rows" or "the server has not published them yet".
194
+ *
195
+ * The SSP writes `rowCount` onto the `_00_query` row in the same statement that
196
+ * registers the view — synchronously, and BEFORE it hands the view's initial
197
+ * edges to the coalescing edge flusher. So the two are not interchangeable:
198
+ * `rowCount > 0` with no edges is the flush window, and only `rowCount === 0`
199
+ * is a genuinely empty query. Polling the edges alone cannot tell those apart
200
+ * no matter how long it waits, which is why this is read alongside them rather
201
+ * than a retry counter.
202
+ *
203
+ * Returns `NONE` (→ null) when the row is not readable or does not exist yet;
204
+ * callers must treat that as "unknown", not as zero.
205
+ */
206
+ export function buildQueryRowCountSelect(): string {
207
+ return 'SELECT VALUE rowCount FROM ONLY $in';
208
+ }
209
+
191
210
  /**
192
211
  * Build the SurrealQL select for a query's SUBQUERY child edges — the
193
212
  * mirror of {@link buildListRefSelect}. `.related()` queries register a
@@ -720,6 +720,10 @@ export class SqliteCacheEngine implements LocalStore {
720
720
  sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
721
721
  }
722
722
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
723
+ // Deterministic fallback, in parity with `sqlite-select.ts`: without it an
724
+ // unordered query renders in insertion order here and in membership order
725
+ // after the server answers, which reshuffles the list on screen.
726
+ else sql += ` ORDER BY id`;
723
727
  if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
724
728
  if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
725
729
  const rows = await this.execRows(sql, bind);
@@ -103,6 +103,14 @@ export async function executeSelect(
103
103
  sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
104
104
  }
105
105
  if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
106
+ // A query with no ORDER BY still has to render in SOME order, and "whatever
107
+ // SQLite hands back" is insertion order — which disagrees with the order the
108
+ // same query gets once it renders from server membership, and disagrees with
109
+ // SurrealDB, whose natural order is by id. That mismatch is visible: the
110
+ // first paint comes from this scan and the second from membership, so an
111
+ // unordered list visibly reshuffled about a second after load. Ordering by
112
+ // id here makes the two agree and makes the result stable across reloads.
113
+ else sql += ` ORDER BY id`;
106
114
  if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
107
115
  if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
108
116
  const rows = execRows(db, sql, bind);