@spooky-sync/core 0.0.1-canary.203 → 0.0.1-canary.204
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 +22 -0
- package/dist/index.js +135 -17
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +11 -8
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/modules/data/data.pending-ids.test.ts +125 -0
- package/src/modules/data/index.ts +64 -0
- package/src/modules/sync/queue/queue-down.test.ts +73 -0
- package/src/modules/sync/queue/queue-down.ts +59 -21
- package/src/modules/sync/scheduler.retry.test.ts +82 -1
- package/src/modules/sync/scheduler.ts +59 -4
- package/src/modules/sync/sync.ts +29 -0
- package/src/services/database/sqlite-cache-engine.ts +13 -8
- package/src/services/database/sqlite-plan-sql.ts +32 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.ts +19 -9
- package/src/sp00ky.ts +1 -0
- package/src/types.ts +6 -0
package/dist/index.d.ts
CHANGED
|
@@ -696,6 +696,13 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
696
696
|
private resolveMembership;
|
|
697
697
|
private readonly settledWrites;
|
|
698
698
|
private readonly settledDeletes;
|
|
699
|
+
private pendingIds;
|
|
700
|
+
private pendingIdsAt;
|
|
701
|
+
private pendingIdsInflight;
|
|
702
|
+
private static readonly PENDING_IDS_TTL_MS;
|
|
703
|
+
/** Drop the cached outbox ids. Cheap; call it on anything that could change
|
|
704
|
+
* `_00_pending_mutations`. */
|
|
705
|
+
private invalidatePendingIds;
|
|
699
706
|
/**
|
|
700
707
|
* Grace period for a settled write. Long enough to cover an SSP round trip
|
|
701
708
|
* that is running slowly (seconds, not milliseconds, when the edge path is
|
|
@@ -841,6 +848,9 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
841
848
|
writes: Set<string>;
|
|
842
849
|
deletes: Set<string>;
|
|
843
850
|
}>;
|
|
851
|
+
/** The uncached read. Also the reload path after an invalidation, so the ids
|
|
852
|
+
* still survive a reload exactly as before. */
|
|
853
|
+
private readPendingRecordIds;
|
|
844
854
|
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
845
855
|
hasSubscribers(hash: string): boolean;
|
|
846
856
|
/**
|
|
@@ -1079,6 +1089,15 @@ interface Sp00kySyncOptions {
|
|
|
1079
1089
|
* up-queue for the session. Defaults to 30000; `0` disables the timeout.
|
|
1080
1090
|
*/
|
|
1081
1091
|
pushTimeoutMs?: number;
|
|
1092
|
+
/**
|
|
1093
|
+
* Max time a single down event (`register`/`sync`/`cleanup`) may take before
|
|
1094
|
+
* it is treated as a network failure and retried. The mirror of
|
|
1095
|
+
* {@link pushTimeoutMs} for the read side, which had no such guard: a
|
|
1096
|
+
* `fn::query::register` that never settled held its slot in the down drain,
|
|
1097
|
+
* and every later registration behind it, for the rest of the session.
|
|
1098
|
+
* Defaults to 30000; `0` disables the timeout.
|
|
1099
|
+
*/
|
|
1100
|
+
downTimeoutMs?: number;
|
|
1082
1101
|
/**
|
|
1083
1102
|
* Transport supervisor. Sync reads its state to report `connection` in
|
|
1084
1103
|
* {@link SyncHealth} so a UI can show "reconnecting…" the instant the socket
|
|
@@ -1148,6 +1167,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1148
1167
|
private readonly degradeAfterFailures;
|
|
1149
1168
|
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
1150
1169
|
private readonly pushTimeoutMs;
|
|
1170
|
+
private readonly downTimeoutMs;
|
|
1151
1171
|
private consecutiveSyncFailures;
|
|
1152
1172
|
private syncHealthStatus;
|
|
1153
1173
|
private lastSyncErrorKind;
|
|
@@ -1378,6 +1398,8 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
1378
1398
|
private handleMutationSettled;
|
|
1379
1399
|
private handleRollback;
|
|
1380
1400
|
private processDownEvent;
|
|
1401
|
+
private withDownTimeout;
|
|
1402
|
+
private runDownEvent;
|
|
1381
1403
|
/**
|
|
1382
1404
|
* Synchronizes a specific query by hash.
|
|
1383
1405
|
* Compares local and remote version arrays and fetches differences.
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as renderOrderSql, c as serializeRow, i as projectedDataSql, l as resolveRelations, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as stableKey } from "./sqlite-open.js";
|
|
2
2
|
import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
|
|
3
3
|
import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
|
|
4
4
|
import pino from "pino";
|
|
@@ -2658,16 +2658,15 @@ var SqliteCacheEngine = class {
|
|
|
2658
2658
|
}
|
|
2659
2659
|
await this.ensureTable(plan.table);
|
|
2660
2660
|
const bind = [];
|
|
2661
|
-
let sql = `SELECT data FROM "${plan.table}"`;
|
|
2661
|
+
let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
|
|
2662
2662
|
if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
2663
2663
|
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
2664
2664
|
else sql += ` ORDER BY id`;
|
|
2665
2665
|
if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
2666
2666
|
if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
2667
2667
|
const rows = await this.execRows(sql, bind);
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
return projected;
|
|
2668
|
+
await resolveRelations(rows, plan.relations, this);
|
|
2669
|
+
return rows;
|
|
2671
2670
|
}
|
|
2672
2671
|
async fetchRelation(req) {
|
|
2673
2672
|
getStats().relationFetches++;
|
|
@@ -2686,14 +2685,18 @@ var SqliteCacheEngine = class {
|
|
|
2686
2685
|
if (ids.length === 0) return [];
|
|
2687
2686
|
await this.ensureTable(table);
|
|
2688
2687
|
const keys = ids.map(stableKey);
|
|
2689
|
-
|
|
2688
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
2689
|
+
const bind = [];
|
|
2690
|
+
const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
|
|
2691
|
+
bind.push(...keys);
|
|
2692
|
+
let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
|
|
2690
2693
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
2691
|
-
let rows = await this.execRows(sql,
|
|
2694
|
+
let rows = await this.execRows(sql, bind);
|
|
2692
2695
|
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
2693
2696
|
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
2694
2697
|
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
2695
2698
|
}
|
|
2696
|
-
return
|
|
2699
|
+
return rows;
|
|
2697
2700
|
}
|
|
2698
2701
|
async getById(table, id) {
|
|
2699
2702
|
await this.ensureTable(table);
|
|
@@ -3586,6 +3589,15 @@ var DataModule = class DataModule {
|
|
|
3586
3589
|
}
|
|
3587
3590
|
settledWrites = /* @__PURE__ */ new Map();
|
|
3588
3591
|
settledDeletes = /* @__PURE__ */ new Map();
|
|
3592
|
+
pendingIds = null;
|
|
3593
|
+
pendingIdsAt = 0;
|
|
3594
|
+
pendingIdsInflight = null;
|
|
3595
|
+
static PENDING_IDS_TTL_MS = 250;
|
|
3596
|
+
/** Drop the cached outbox ids. Cheap; call it on anything that could change
|
|
3597
|
+
* `_00_pending_mutations`. */
|
|
3598
|
+
invalidatePendingIds() {
|
|
3599
|
+
this.pendingIds = null;
|
|
3600
|
+
}
|
|
3589
3601
|
/**
|
|
3590
3602
|
* Grace period for a settled write. Long enough to cover an SSP round trip
|
|
3591
3603
|
* that is running slowly (seconds, not milliseconds, when the edge path is
|
|
@@ -3604,6 +3616,7 @@ var DataModule = class DataModule {
|
|
|
3604
3616
|
* disappear immediately, which is what makes this safe.
|
|
3605
3617
|
*/
|
|
3606
3618
|
noteWriteSettled(recordId, mutationType) {
|
|
3619
|
+
this.invalidatePendingIds();
|
|
3607
3620
|
const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
|
|
3608
3621
|
if (mutationType === "delete") this.settledDeletes.set(recordId, until);
|
|
3609
3622
|
else this.settledWrites.set(recordId, until);
|
|
@@ -3989,6 +4002,24 @@ var DataModule = class DataModule {
|
|
|
3989
4002
|
* briefly hide an optimistic write but never resurrects a deleted row.
|
|
3990
4003
|
*/
|
|
3991
4004
|
async getPendingRecordIds() {
|
|
4005
|
+
const now = Date.now();
|
|
4006
|
+
const cached = this.pendingIds;
|
|
4007
|
+
if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) return {
|
|
4008
|
+
writes: new Set(cached.writes),
|
|
4009
|
+
deletes: new Set(cached.deletes)
|
|
4010
|
+
};
|
|
4011
|
+
if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
|
|
4012
|
+
this.pendingIdsInflight = null;
|
|
4013
|
+
});
|
|
4014
|
+
const fresh = await this.pendingIdsInflight;
|
|
4015
|
+
return {
|
|
4016
|
+
writes: new Set(fresh.writes),
|
|
4017
|
+
deletes: new Set(fresh.deletes)
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
4020
|
+
/** The uncached read. Also the reload path after an invalidation, so the ids
|
|
4021
|
+
* still survive a reload exactly as before. */
|
|
4022
|
+
async readPendingRecordIds() {
|
|
3992
4023
|
const writes = /* @__PURE__ */ new Set();
|
|
3993
4024
|
const deletes = /* @__PURE__ */ new Set();
|
|
3994
4025
|
try {
|
|
@@ -4004,7 +4035,16 @@ var DataModule = class DataModule {
|
|
|
4004
4035
|
err,
|
|
4005
4036
|
Category: "sp00ky-client::DataModule::getPendingRecordIds"
|
|
4006
4037
|
}, "Failed to read pending mutations; optimistic writes may be briefly hidden");
|
|
4038
|
+
return {
|
|
4039
|
+
writes,
|
|
4040
|
+
deletes
|
|
4041
|
+
};
|
|
4007
4042
|
}
|
|
4043
|
+
this.pendingIds = {
|
|
4044
|
+
writes,
|
|
4045
|
+
deletes
|
|
4046
|
+
};
|
|
4047
|
+
this.pendingIdsAt = Date.now();
|
|
4008
4048
|
return {
|
|
4009
4049
|
writes,
|
|
4010
4050
|
deletes
|
|
@@ -4321,6 +4361,7 @@ var DataModule = class DataModule {
|
|
|
4321
4361
|
record: target,
|
|
4322
4362
|
tableName
|
|
4323
4363
|
};
|
|
4364
|
+
this.invalidatePendingIds();
|
|
4324
4365
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4325
4366
|
this.logger.debug({
|
|
4326
4367
|
id,
|
|
@@ -4374,6 +4415,7 @@ var DataModule = class DataModule {
|
|
|
4374
4415
|
beforeRecord: beforeRecord || void 0,
|
|
4375
4416
|
options: pushEventOptions
|
|
4376
4417
|
};
|
|
4418
|
+
this.invalidatePendingIds();
|
|
4377
4419
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4378
4420
|
this.logger.debug({
|
|
4379
4421
|
id,
|
|
@@ -4419,6 +4461,7 @@ var DataModule = class DataModule {
|
|
|
4419
4461
|
mutation_id: mutationId,
|
|
4420
4462
|
record_id: rid
|
|
4421
4463
|
};
|
|
4464
|
+
this.invalidatePendingIds();
|
|
4422
4465
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4423
4466
|
this.logger.debug({
|
|
4424
4467
|
id,
|
|
@@ -5071,9 +5114,43 @@ var DownQueue = class {
|
|
|
5071
5114
|
}
|
|
5072
5115
|
async next(fn) {
|
|
5073
5116
|
const event = this.queue.shift();
|
|
5074
|
-
if (event)
|
|
5117
|
+
if (!event) return;
|
|
5118
|
+
const error = await this.run(event, fn);
|
|
5119
|
+
if (error !== void 0) throw error;
|
|
5120
|
+
}
|
|
5121
|
+
/**
|
|
5122
|
+
* The next event whose hash is NOT already being processed, or `undefined`
|
|
5123
|
+
* when every remaining event is blocked on a busy hash.
|
|
5124
|
+
*
|
|
5125
|
+
* Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
|
|
5126
|
+
* `register` for the same query — but the queue enforced it globally, so one
|
|
5127
|
+
* registration RPC at a time was the ceiling for the whole client. An event
|
|
5128
|
+
* for a busy hash keeps its place here (it is skipped, not reordered), so
|
|
5129
|
+
* per-hash ordering is preserved exactly while independent hashes proceed
|
|
5130
|
+
* concurrently.
|
|
5131
|
+
*/
|
|
5132
|
+
takeNext(busy) {
|
|
5133
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
5134
|
+
const event = this.queue[i];
|
|
5135
|
+
if (busy.has(event.payload.hash)) continue;
|
|
5136
|
+
this.queue.splice(i, 1);
|
|
5137
|
+
return event;
|
|
5138
|
+
}
|
|
5139
|
+
}
|
|
5140
|
+
/**
|
|
5141
|
+
* Process one event, applying the re-head / rotate failure policy.
|
|
5142
|
+
*
|
|
5143
|
+
* NEVER rejects: it RETURNS the error instead (`undefined` on success). A
|
|
5144
|
+
* concurrent drain has other work in flight when one event fails, and a
|
|
5145
|
+
* rejection would either take that work down with it or have to be caught at
|
|
5146
|
+
* every call site. `next` reinstates the throwing contract for the serial
|
|
5147
|
+
* callers that still want it.
|
|
5148
|
+
*/
|
|
5149
|
+
async run(event, fn) {
|
|
5150
|
+
try {
|
|
5075
5151
|
await fn(event);
|
|
5076
5152
|
this.failures.delete(event);
|
|
5153
|
+
return;
|
|
5077
5154
|
} catch (error) {
|
|
5078
5155
|
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
5079
5156
|
this.failures.set(event, attempts);
|
|
@@ -5087,7 +5164,7 @@ var DownQueue = class {
|
|
|
5087
5164
|
rotated: starvingOthers,
|
|
5088
5165
|
Category: "sp00ky-client::DownQueue::next"
|
|
5089
5166
|
}, "Failed to process query");
|
|
5090
|
-
|
|
5167
|
+
return error;
|
|
5091
5168
|
}
|
|
5092
5169
|
}
|
|
5093
5170
|
};
|
|
@@ -5452,6 +5529,17 @@ var SyncEngine = class {
|
|
|
5452
5529
|
*/
|
|
5453
5530
|
/** Backoff for re-draining a queue that halted on an error. */
|
|
5454
5531
|
const RETRY_BASE_MS = 500;
|
|
5532
|
+
/**
|
|
5533
|
+
* How many down events may be in flight at once.
|
|
5534
|
+
*
|
|
5535
|
+
* The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
|
|
5536
|
+
* time for the WHOLE client. Measured in production that drained at roughly one
|
|
5537
|
+
* event per 8.8s, so a list that registers a query per scrolled-to window took
|
|
5538
|
+
* minutes to fill — the rows were already cached, only the registration lagged.
|
|
5539
|
+
* Ordering is a per-hash requirement, not a global one (see `takeNext`), so
|
|
5540
|
+
* independent hashes can go in parallel. Bounded to stay polite to the SSP.
|
|
5541
|
+
*/
|
|
5542
|
+
const MAX_CONCURRENT_DOWN = 4;
|
|
5455
5543
|
const RETRY_MAX_MS = 15e3;
|
|
5456
5544
|
var SyncScheduler = class {
|
|
5457
5545
|
isSyncingUp = false;
|
|
@@ -5593,14 +5681,35 @@ var SyncScheduler = class {
|
|
|
5593
5681
|
}
|
|
5594
5682
|
this.isSyncingDown = true;
|
|
5595
5683
|
let processedAny = false;
|
|
5684
|
+
const busy = /* @__PURE__ */ new Set();
|
|
5685
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
5686
|
+
let failure;
|
|
5596
5687
|
try {
|
|
5597
|
-
|
|
5598
|
-
if (this.
|
|
5599
|
-
|
|
5600
|
-
|
|
5688
|
+
for (;;) {
|
|
5689
|
+
if (this.paused) break;
|
|
5690
|
+
const yieldToUp = this.upQueue.size > 0;
|
|
5691
|
+
while (failure === void 0 && !yieldToUp && inFlight.size < MAX_CONCURRENT_DOWN) {
|
|
5692
|
+
const event = this.downQueue.takeNext(busy);
|
|
5693
|
+
if (!event) break;
|
|
5694
|
+
const hash = event.payload.hash;
|
|
5695
|
+
busy.add(hash);
|
|
5696
|
+
processedAny = true;
|
|
5697
|
+
const task = this.downQueue.run(event, this.onProcessDown).then((error) => {
|
|
5698
|
+
if (error !== void 0 && failure === void 0) failure = error;
|
|
5699
|
+
}).finally(() => {
|
|
5700
|
+
busy.delete(hash);
|
|
5701
|
+
inFlight.delete(task);
|
|
5702
|
+
});
|
|
5703
|
+
inFlight.add(task);
|
|
5704
|
+
}
|
|
5705
|
+
if (inFlight.size === 0) break;
|
|
5706
|
+
await Promise.race(inFlight);
|
|
5707
|
+
if (yieldToUp && inFlight.size === 0) break;
|
|
5601
5708
|
}
|
|
5709
|
+
if (failure !== void 0) throw failure;
|
|
5602
5710
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5603
5711
|
this.downRetryAttempt = 0;
|
|
5712
|
+
if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
|
|
5604
5713
|
} catch (error) {
|
|
5605
5714
|
this.onSyncOutcome?.(false, error);
|
|
5606
5715
|
this.scheduleDownRetry();
|
|
@@ -5691,6 +5800,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5691
5800
|
degradeAfterFailures;
|
|
5692
5801
|
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
5693
5802
|
pushTimeoutMs;
|
|
5803
|
+
downTimeoutMs;
|
|
5694
5804
|
consecutiveSyncFailures = 0;
|
|
5695
5805
|
syncHealthStatus = "healthy";
|
|
5696
5806
|
lastSyncErrorKind;
|
|
@@ -5862,6 +5972,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5862
5972
|
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
5863
5973
|
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
5864
5974
|
this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
|
|
5975
|
+
this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 3e4);
|
|
5865
5976
|
this.connectionSupervisor = options?.connectionSupervisor;
|
|
5866
5977
|
}
|
|
5867
5978
|
/**
|
|
@@ -6544,6 +6655,12 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6544
6655
|
event,
|
|
6545
6656
|
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
6546
6657
|
}, "Processing down event");
|
|
6658
|
+
return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
|
|
6659
|
+
}
|
|
6660
|
+
withDownTimeout(promise, label) {
|
|
6661
|
+
return withTimeout(promise, this.downTimeoutMs, `Down event timed out after ${this.downTimeoutMs}ms (${label})`);
|
|
6662
|
+
}
|
|
6663
|
+
async runDownEvent(event) {
|
|
6547
6664
|
switch (event.type) {
|
|
6548
6665
|
case "register": return this.registerQuery(event.payload.hash);
|
|
6549
6666
|
case "sync": return this.syncQuery(event.payload.hash);
|
|
@@ -7094,8 +7211,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7094
7211
|
|
|
7095
7212
|
//#endregion
|
|
7096
7213
|
//#region src/modules/devtools/index.ts
|
|
7097
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7098
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7214
|
+
const CORE_VERSION = "0.0.1-canary.204";
|
|
7215
|
+
const WASM_VERSION = "0.0.1-canary.204";
|
|
7099
7216
|
const SURREAL_VERSION = "3.0.3";
|
|
7100
7217
|
var DevToolsService = class DevToolsService {
|
|
7101
7218
|
eventsHistory = [];
|
|
@@ -11718,6 +11835,7 @@ var Sp00kyClient = class {
|
|
|
11718
11835
|
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
11719
11836
|
degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
|
|
11720
11837
|
pushTimeoutMs: this.config.pushTimeoutMs,
|
|
11838
|
+
downTimeoutMs: this.config.downTimeoutMs,
|
|
11721
11839
|
connectionSupervisor: this.connectionSupervisor
|
|
11722
11840
|
});
|
|
11723
11841
|
this.featureFlags = new FeatureFlagModule({
|
|
@@ -11772,7 +11890,7 @@ var Sp00kyClient = class {
|
|
|
11772
11890
|
return new TabsCoordinator({
|
|
11773
11891
|
tabId,
|
|
11774
11892
|
fingerprint: computeTabsFingerprint({
|
|
11775
|
-
coreVersion: "0.0.1-canary.
|
|
11893
|
+
coreVersion: "0.0.1-canary.204",
|
|
11776
11894
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11777
11895
|
endpoint: this.config.database.endpoint ?? "",
|
|
11778
11896
|
namespace: this.config.database.namespace,
|
package/dist/sqlite-open.js
CHANGED
|
@@ -170,6 +170,33 @@ function reviveRow(json) {
|
|
|
170
170
|
return v;
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
175
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
176
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
177
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
178
|
+
*
|
|
179
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
180
|
+
* what both paths did before, and verified so against real SQLite:
|
|
181
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
182
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
183
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
184
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
185
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
186
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
187
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
188
|
+
*
|
|
189
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
190
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
191
|
+
*
|
|
192
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
193
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
194
|
+
*/
|
|
195
|
+
function projectedDataSql(fields, bind) {
|
|
196
|
+
const keys = ["id", ...fields];
|
|
197
|
+
for (const k of keys) bind.push(k);
|
|
198
|
+
return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
|
|
199
|
+
}
|
|
173
200
|
function project(row, fields) {
|
|
174
201
|
const out = {};
|
|
175
202
|
for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
|
|
@@ -273,4 +300,4 @@ async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
|
273
300
|
}
|
|
274
301
|
|
|
275
302
|
//#endregion
|
|
276
|
-
export {
|
|
303
|
+
export { renderOrderSql as a, serializeRow as c, projectedDataSql as i, resolveRelations as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, stableKey as u };
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as renderOrderSql, i as projectedDataSql, l as resolveRelations, n as openDb, o as renderWhereSql, r as project, s as reviveRow, u as stableKey } from "./sqlite-open.js";
|
|
2
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
|
|
4
4
|
//#region src/services/database/sqlite-select.ts
|
|
@@ -16,14 +16,18 @@ function selectByIds(db, table, ids, opts) {
|
|
|
16
16
|
if (ids.length === 0) return [];
|
|
17
17
|
ensureTable(db, table);
|
|
18
18
|
const keys = ids.map(stableKey);
|
|
19
|
-
|
|
19
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
20
|
+
const bind = [];
|
|
21
|
+
const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
|
|
22
|
+
bind.push(...keys);
|
|
23
|
+
let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
|
|
20
24
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
21
|
-
let rows = execRows(db, sql,
|
|
25
|
+
let rows = execRows(db, sql, bind);
|
|
22
26
|
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
23
27
|
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
24
28
|
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
25
29
|
}
|
|
26
|
-
return
|
|
30
|
+
return rows;
|
|
27
31
|
}
|
|
28
32
|
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
29
33
|
function fetchRelation(db, req) {
|
|
@@ -57,17 +61,16 @@ async function executeSelect(plan, params, db) {
|
|
|
57
61
|
}
|
|
58
62
|
ensureTable(db, plan.table);
|
|
59
63
|
const bind = [];
|
|
60
|
-
let sql = `SELECT data FROM "${plan.table}"`;
|
|
64
|
+
let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
|
|
61
65
|
if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
62
66
|
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
63
67
|
else sql += ` ORDER BY id`;
|
|
64
68
|
if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
65
69
|
if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
66
70
|
const rows = execRows(db, sql, bind);
|
|
67
|
-
|
|
68
|
-
await resolveRelations(projected, plan.relations, fetcher);
|
|
71
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
69
72
|
return {
|
|
70
|
-
rows
|
|
73
|
+
rows,
|
|
71
74
|
relationFetches: counter.n
|
|
72
75
|
};
|
|
73
76
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -654,6 +654,12 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
654
654
|
* `0` disables. Defaults to `30_000`.
|
|
655
655
|
*/
|
|
656
656
|
pushTimeoutMs?: number;
|
|
657
|
+
/**
|
|
658
|
+
* Max time a single down event (`register`/`sync`/`cleanup`) may take before
|
|
659
|
+
* it is retried. Mirror of {@link pushTimeoutMs} for the read side.
|
|
660
|
+
* Defaults to 30000; `0` disables the timeout.
|
|
661
|
+
*/
|
|
662
|
+
downTimeoutMs?: number;
|
|
657
663
|
}
|
|
658
664
|
/** Tunables for sync-health reporting. See {@link Sp00kyConfig.syncHealth}. */
|
|
659
665
|
interface SyncHealthConfig {
|
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.204",
|
|
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.204",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.204",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `getPendingRecordIds` used to run a full `SELECT ... FROM
|
|
7
|
+
* _00_pending_mutations` on EVERY materialization of EVERY query — a round trip
|
|
8
|
+
* down the local engine's single-flight op queue, paid tens of times per ingest
|
|
9
|
+
* against an outbox that is usually empty. It is now cached, invalidated when
|
|
10
|
+
* the outbox actually changes, with a short TTL as a backstop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function makeLogger(): any {
|
|
14
|
+
const noop = () => {};
|
|
15
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
16
|
+
logger.child = () => logger;
|
|
17
|
+
return logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** DataModule over a local store that counts outbox reads and can be told what
|
|
21
|
+
* to return. */
|
|
22
|
+
function makeModule(rows: { recordId: RecordId<string>; mutationType: string }[] = []) {
|
|
23
|
+
const state = { rows, reads: 0 };
|
|
24
|
+
const local = {
|
|
25
|
+
query: vi.fn(async () => {
|
|
26
|
+
state.reads++;
|
|
27
|
+
return [state.rows];
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
const dm = new DataModule(
|
|
31
|
+
{} as any,
|
|
32
|
+
local as any,
|
|
33
|
+
{ tables: [] } as any,
|
|
34
|
+
makeLogger(),
|
|
35
|
+
100
|
|
36
|
+
);
|
|
37
|
+
return { dm, state };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pending = (id: string, mutationType = 'update') => ({
|
|
41
|
+
recordId: new RecordId('game', id),
|
|
42
|
+
mutationType,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('DataModule pending-id cache', () => {
|
|
46
|
+
beforeEach(() => vi.useFakeTimers());
|
|
47
|
+
afterEach(() => vi.useRealTimers());
|
|
48
|
+
|
|
49
|
+
it('reads the outbox once for repeated calls', async () => {
|
|
50
|
+
const { dm, state } = makeModule([pending('a')]);
|
|
51
|
+
await dm.getPendingRecordIds();
|
|
52
|
+
await dm.getPendingRecordIds();
|
|
53
|
+
await dm.getPendingRecordIds();
|
|
54
|
+
expect(state.reads).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('collapses a burst of CONCURRENT calls into one read', async () => {
|
|
58
|
+
// The real shape: one ingest fans out to many queries materializing at once.
|
|
59
|
+
const { dm, state } = makeModule([pending('a')]);
|
|
60
|
+
await Promise.all([
|
|
61
|
+
dm.getPendingRecordIds(),
|
|
62
|
+
dm.getPendingRecordIds(),
|
|
63
|
+
dm.getPendingRecordIds(),
|
|
64
|
+
dm.getPendingRecordIds(),
|
|
65
|
+
]);
|
|
66
|
+
expect(state.reads).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('hands out COPIES, so a caller mutating the sets cannot poison the cache', async () => {
|
|
70
|
+
// buildRenderIds merges the settled-write ids into what it gets back.
|
|
71
|
+
const { dm } = makeModule([pending('a')]);
|
|
72
|
+
const first = await dm.getPendingRecordIds();
|
|
73
|
+
first.writes.add('game:injected');
|
|
74
|
+
const second = await dm.getPendingRecordIds();
|
|
75
|
+
expect(second.writes.has('game:injected')).toBe(false);
|
|
76
|
+
expect([...second.writes]).toEqual(['game:a']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('re-reads after a mutation settles (its outbox row is gone)', async () => {
|
|
80
|
+
const { dm, state } = makeModule([pending('a')]);
|
|
81
|
+
await dm.getPendingRecordIds();
|
|
82
|
+
expect(state.reads).toBe(1);
|
|
83
|
+
|
|
84
|
+
state.rows = [];
|
|
85
|
+
dm.noteWriteSettled('game:a', 'update');
|
|
86
|
+
const after = await dm.getPendingRecordIds();
|
|
87
|
+
|
|
88
|
+
expect(state.reads).toBe(2);
|
|
89
|
+
expect(after.writes.size).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('re-reads once the TTL backstop lapses', async () => {
|
|
93
|
+
// Covers any path that removes an outbox row without telling us: staleness
|
|
94
|
+
// is bounded to a tick rather than lasting the session.
|
|
95
|
+
const { dm, state } = makeModule([pending('a')]);
|
|
96
|
+
await dm.getPendingRecordIds();
|
|
97
|
+
expect(state.reads).toBe(1);
|
|
98
|
+
|
|
99
|
+
vi.setSystemTime(Date.now() + 1_000);
|
|
100
|
+
await dm.getPendingRecordIds();
|
|
101
|
+
expect(state.reads).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does NOT cache a failed read', async () => {
|
|
105
|
+
// Empty sets are this call's fallback, not a claim that the outbox is empty.
|
|
106
|
+
const { dm, state } = makeModule([pending('a')]);
|
|
107
|
+
(dm as any).local.query = vi.fn(async () => {
|
|
108
|
+
state.reads++;
|
|
109
|
+
throw new Error('engine down');
|
|
110
|
+
});
|
|
111
|
+
const failed = await dm.getPendingRecordIds();
|
|
112
|
+
expect(failed.writes.size).toBe(0);
|
|
113
|
+
expect(state.reads).toBe(1);
|
|
114
|
+
|
|
115
|
+
await dm.getPendingRecordIds();
|
|
116
|
+
expect(state.reads).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('splits writes from deletes', async () => {
|
|
120
|
+
const { dm } = makeModule([pending('a'), pending('b', 'delete')]);
|
|
121
|
+
const { writes, deletes } = await dm.getPendingRecordIds();
|
|
122
|
+
expect([...writes]).toEqual(['game:a']);
|
|
123
|
+
expect([...deletes]).toEqual(['game:b']);
|
|
124
|
+
});
|
|
125
|
+
});
|