@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 +4 -1
- package/dist/index.js +43 -17
- package/dist/sqlite-worker.js +1 -0
- package/package.json +3 -3
- package/src/modules/data/data.membership.test.ts +98 -0
- package/src/modules/data/index.ts +53 -14
- package/src/modules/sync/sync.ts +16 -12
- package/src/modules/sync/utils.ts +19 -0
- package/src/services/database/sqlite-cache-engine.ts +4 -0
- package/src/services/database/sqlite-select.ts +8 -0
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
|
|
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
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
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)
|
|
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)
|
|
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.
|
|
6870
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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,
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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(
|
|
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
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
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
|
|
1230
|
-
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -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<
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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);
|