@spooky-sync/core 0.0.1-canary.203 → 0.0.1-canary.205
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 +170 -40
- package/dist/index.js +733 -187
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +11 -8
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +58 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/data/data.pending-ids.test.ts +125 -0
- package/src/modules/data/index.ts +64 -0
- package/src/modules/devtools/index.ts +3 -1
- package/src/modules/sync/engine.ts +76 -58
- 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 +94 -8
- package/src/services/database/cache-engine.ts +33 -0
- package/src/services/database/sqlite-cache-engine.test.ts +58 -0
- package/src/services/database/sqlite-cache-engine.ts +84 -9
- 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/services/database/surreal-cache-engine.ts +20 -0
- package/src/services/stream-processor/index.ts +471 -225
- package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
- package/src/services/stream-processor/wasm-types.ts +32 -1
- package/src/services/tabs/broker.test.ts +49 -0
- package/src/services/tabs/coordinator.test.ts +6 -1
- package/src/services/tabs/coordinator.ts +35 -3
- package/src/services/tabs/tabs-broker-worker.ts +66 -12
- package/src/sp00ky.ts +55 -8
- package/src/types.ts +27 -13
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";
|
|
@@ -1616,6 +1616,21 @@ var SurrealCacheEngine = class extends LocalDatabaseService {
|
|
|
1616
1616
|
if (typeof id !== "string") return id;
|
|
1617
1617
|
return new RecordId(table, id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id);
|
|
1618
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* `(id, _00_rv)` of every row per table. Runs on the main thread against the
|
|
1621
|
+
* embedded engine (no worker here), which is why the circuit prime caps how
|
|
1622
|
+
* much it is willing to read through this path.
|
|
1623
|
+
*/
|
|
1624
|
+
async scanVersions(tables) {
|
|
1625
|
+
const out = {};
|
|
1626
|
+
for (const table of tables) try {
|
|
1627
|
+
const [rows] = await this.query(`SELECT id, _00_rv FROM ${table};`);
|
|
1628
|
+
out[table] = (rows ?? []).map((r) => [stableKey(r.id), Number(r._00_rv) || 0]);
|
|
1629
|
+
} catch {
|
|
1630
|
+
out[table] = [];
|
|
1631
|
+
}
|
|
1632
|
+
return out;
|
|
1633
|
+
}
|
|
1619
1634
|
async getById(table, id) {
|
|
1620
1635
|
const [row] = await this.query("SELECT * FROM ONLY $__id;", { __id: this.toRecordId(table, id) });
|
|
1621
1636
|
return row && typeof row === "object" ? row : null;
|
|
@@ -2136,8 +2151,10 @@ var PortSqliteTransport = class extends BaseTransport {
|
|
|
2136
2151
|
* on a fresh bucket throws "no such table". Keep in sync with the systemSchema
|
|
2137
2152
|
* block in `local-migrator.ts`.
|
|
2138
2153
|
*/
|
|
2154
|
+
const SNAPSHOT_TABLE = "_00_circuit_snapshot";
|
|
2139
2155
|
const SYSTEM_TABLES = [
|
|
2140
2156
|
"_00_stream_processor_state",
|
|
2157
|
+
"_00_circuit_snapshot",
|
|
2141
2158
|
"_00_query",
|
|
2142
2159
|
"_00_preload",
|
|
2143
2160
|
"_00_window",
|
|
@@ -2259,7 +2276,7 @@ var SqliteCacheEngine = class {
|
|
|
2259
2276
|
diag.freelistBytes = rows?.[0]?.freelist;
|
|
2260
2277
|
if (opts?.tableCounts) {
|
|
2261
2278
|
const { rows: tables } = await this.call("exec", { sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" });
|
|
2262
|
-
const names = (tables ?? []).map((r) => r.name);
|
|
2279
|
+
const names = (tables ?? []).map((r) => r.name).filter((n) => n !== SNAPSHOT_TABLE);
|
|
2263
2280
|
if (names.length) {
|
|
2264
2281
|
const sql = names.map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, "\"\"")}"`).join(" UNION ALL ");
|
|
2265
2282
|
const { rows: counts } = await this.call("exec", { sql });
|
|
@@ -2658,16 +2675,15 @@ var SqliteCacheEngine = class {
|
|
|
2658
2675
|
}
|
|
2659
2676
|
await this.ensureTable(plan.table);
|
|
2660
2677
|
const bind = [];
|
|
2661
|
-
let sql = `SELECT data FROM "${plan.table}"`;
|
|
2678
|
+
let sql = `SELECT ${plan.select ? projectedDataSql(plan.select, bind) : "data"} FROM "${plan.table}"`;
|
|
2662
2679
|
if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
2663
2680
|
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
2664
2681
|
else sql += ` ORDER BY id`;
|
|
2665
2682
|
if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
2666
2683
|
if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
2667
2684
|
const rows = await this.execRows(sql, bind);
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
return projected;
|
|
2685
|
+
await resolveRelations(rows, plan.relations, this);
|
|
2686
|
+
return rows;
|
|
2671
2687
|
}
|
|
2672
2688
|
async fetchRelation(req) {
|
|
2673
2689
|
getStats().relationFetches++;
|
|
@@ -2686,19 +2702,74 @@ var SqliteCacheEngine = class {
|
|
|
2686
2702
|
if (ids.length === 0) return [];
|
|
2687
2703
|
await this.ensureTable(table);
|
|
2688
2704
|
const keys = ids.map(stableKey);
|
|
2689
|
-
|
|
2705
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
2706
|
+
const bind = [];
|
|
2707
|
+
const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : "data";
|
|
2708
|
+
bind.push(...keys);
|
|
2709
|
+
let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
|
|
2690
2710
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
2691
|
-
let rows = await this.execRows(sql,
|
|
2711
|
+
let rows = await this.execRows(sql, bind);
|
|
2692
2712
|
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
2693
2713
|
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
2694
2714
|
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
2695
2715
|
}
|
|
2696
|
-
return
|
|
2716
|
+
return rows;
|
|
2697
2717
|
}
|
|
2698
2718
|
async getById(table, id) {
|
|
2699
2719
|
await this.ensureTable(table);
|
|
2700
2720
|
return (await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]))[0] ?? null;
|
|
2701
2721
|
}
|
|
2722
|
+
/**
|
|
2723
|
+
* `(id, _00_rv)` of every row per table, straight off the worker: no body
|
|
2724
|
+
* parse, one round trip per table. A table that does not exist yet reads as
|
|
2725
|
+
* empty rather than failing the whole scan.
|
|
2726
|
+
*/
|
|
2727
|
+
async scanVersions(tables) {
|
|
2728
|
+
const out = {};
|
|
2729
|
+
for (const table of tables) try {
|
|
2730
|
+
const { rows } = await this.call("exec", { sql: `SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table.replace(/"/g, "\"\"")}"` });
|
|
2731
|
+
out[table] = (rows ?? []).map((r) => [r.id, Number(r.rv) || 0]);
|
|
2732
|
+
} catch {
|
|
2733
|
+
out[table] = [];
|
|
2734
|
+
}
|
|
2735
|
+
return out;
|
|
2736
|
+
}
|
|
2737
|
+
async getSnapshot(key) {
|
|
2738
|
+
const { rows } = await this.call("exec", {
|
|
2739
|
+
sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|
|
2740
|
+
bind: [key, `${key}:meta`]
|
|
2741
|
+
});
|
|
2742
|
+
let bytes = null;
|
|
2743
|
+
let meta = null;
|
|
2744
|
+
for (const r of rows ?? []) if (r.id === key) {
|
|
2745
|
+
if (r.data instanceof Uint8Array) bytes = r.data;
|
|
2746
|
+
else if (r.data instanceof ArrayBuffer) bytes = new Uint8Array(r.data);
|
|
2747
|
+
else if (typeof r.data === "string") bytes = new TextEncoder().encode(r.data);
|
|
2748
|
+
} else if (typeof r.data === "string") try {
|
|
2749
|
+
meta = JSON.parse(r.data);
|
|
2750
|
+
} catch {
|
|
2751
|
+
meta = null;
|
|
2752
|
+
}
|
|
2753
|
+
return bytes && meta ? {
|
|
2754
|
+
bytes,
|
|
2755
|
+
meta
|
|
2756
|
+
} : null;
|
|
2757
|
+
}
|
|
2758
|
+
async putSnapshot(key, bytes, meta) {
|
|
2759
|
+
await this.call("batch", [{
|
|
2760
|
+
sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
|
|
2761
|
+
bind: [key, bytes]
|
|
2762
|
+
}, {
|
|
2763
|
+
sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
|
|
2764
|
+
bind: [`${key}:meta`, JSON.stringify(meta)]
|
|
2765
|
+
}]);
|
|
2766
|
+
}
|
|
2767
|
+
async deleteSnapshot(key) {
|
|
2768
|
+
await this.call("run", {
|
|
2769
|
+
sql: `DELETE FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|
|
2770
|
+
bind: [key, `${key}:meta`]
|
|
2771
|
+
});
|
|
2772
|
+
}
|
|
2702
2773
|
async upsert(table, id, data, mode) {
|
|
2703
2774
|
await this.ensureTable(table);
|
|
2704
2775
|
const key = stableKey(id);
|
|
@@ -3586,6 +3657,15 @@ var DataModule = class DataModule {
|
|
|
3586
3657
|
}
|
|
3587
3658
|
settledWrites = /* @__PURE__ */ new Map();
|
|
3588
3659
|
settledDeletes = /* @__PURE__ */ new Map();
|
|
3660
|
+
pendingIds = null;
|
|
3661
|
+
pendingIdsAt = 0;
|
|
3662
|
+
pendingIdsInflight = null;
|
|
3663
|
+
static PENDING_IDS_TTL_MS = 250;
|
|
3664
|
+
/** Drop the cached outbox ids. Cheap; call it on anything that could change
|
|
3665
|
+
* `_00_pending_mutations`. */
|
|
3666
|
+
invalidatePendingIds() {
|
|
3667
|
+
this.pendingIds = null;
|
|
3668
|
+
}
|
|
3589
3669
|
/**
|
|
3590
3670
|
* Grace period for a settled write. Long enough to cover an SSP round trip
|
|
3591
3671
|
* that is running slowly (seconds, not milliseconds, when the edge path is
|
|
@@ -3604,6 +3684,7 @@ var DataModule = class DataModule {
|
|
|
3604
3684
|
* disappear immediately, which is what makes this safe.
|
|
3605
3685
|
*/
|
|
3606
3686
|
noteWriteSettled(recordId, mutationType) {
|
|
3687
|
+
this.invalidatePendingIds();
|
|
3607
3688
|
const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
|
|
3608
3689
|
if (mutationType === "delete") this.settledDeletes.set(recordId, until);
|
|
3609
3690
|
else this.settledWrites.set(recordId, until);
|
|
@@ -3989,6 +4070,24 @@ var DataModule = class DataModule {
|
|
|
3989
4070
|
* briefly hide an optimistic write but never resurrects a deleted row.
|
|
3990
4071
|
*/
|
|
3991
4072
|
async getPendingRecordIds() {
|
|
4073
|
+
const now = Date.now();
|
|
4074
|
+
const cached = this.pendingIds;
|
|
4075
|
+
if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) return {
|
|
4076
|
+
writes: new Set(cached.writes),
|
|
4077
|
+
deletes: new Set(cached.deletes)
|
|
4078
|
+
};
|
|
4079
|
+
if (!this.pendingIdsInflight) this.pendingIdsInflight = this.readPendingRecordIds().finally(() => {
|
|
4080
|
+
this.pendingIdsInflight = null;
|
|
4081
|
+
});
|
|
4082
|
+
const fresh = await this.pendingIdsInflight;
|
|
4083
|
+
return {
|
|
4084
|
+
writes: new Set(fresh.writes),
|
|
4085
|
+
deletes: new Set(fresh.deletes)
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
/** The uncached read. Also the reload path after an invalidation, so the ids
|
|
4089
|
+
* still survive a reload exactly as before. */
|
|
4090
|
+
async readPendingRecordIds() {
|
|
3992
4091
|
const writes = /* @__PURE__ */ new Set();
|
|
3993
4092
|
const deletes = /* @__PURE__ */ new Set();
|
|
3994
4093
|
try {
|
|
@@ -4004,7 +4103,16 @@ var DataModule = class DataModule {
|
|
|
4004
4103
|
err,
|
|
4005
4104
|
Category: "sp00ky-client::DataModule::getPendingRecordIds"
|
|
4006
4105
|
}, "Failed to read pending mutations; optimistic writes may be briefly hidden");
|
|
4106
|
+
return {
|
|
4107
|
+
writes,
|
|
4108
|
+
deletes
|
|
4109
|
+
};
|
|
4007
4110
|
}
|
|
4111
|
+
this.pendingIds = {
|
|
4112
|
+
writes,
|
|
4113
|
+
deletes
|
|
4114
|
+
};
|
|
4115
|
+
this.pendingIdsAt = Date.now();
|
|
4008
4116
|
return {
|
|
4009
4117
|
writes,
|
|
4010
4118
|
deletes
|
|
@@ -4321,6 +4429,7 @@ var DataModule = class DataModule {
|
|
|
4321
4429
|
record: target,
|
|
4322
4430
|
tableName
|
|
4323
4431
|
};
|
|
4432
|
+
this.invalidatePendingIds();
|
|
4324
4433
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4325
4434
|
this.logger.debug({
|
|
4326
4435
|
id,
|
|
@@ -4374,6 +4483,7 @@ var DataModule = class DataModule {
|
|
|
4374
4483
|
beforeRecord: beforeRecord || void 0,
|
|
4375
4484
|
options: pushEventOptions
|
|
4376
4485
|
};
|
|
4486
|
+
this.invalidatePendingIds();
|
|
4377
4487
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4378
4488
|
this.logger.debug({
|
|
4379
4489
|
id,
|
|
@@ -4419,6 +4529,7 @@ var DataModule = class DataModule {
|
|
|
4419
4529
|
mutation_id: mutationId,
|
|
4420
4530
|
record_id: rid
|
|
4421
4531
|
};
|
|
4532
|
+
this.invalidatePendingIds();
|
|
4422
4533
|
for (const callback of this.mutationCallbacks) callback([mutationEvent]);
|
|
4423
4534
|
this.logger.debug({
|
|
4424
4535
|
id,
|
|
@@ -5071,9 +5182,43 @@ var DownQueue = class {
|
|
|
5071
5182
|
}
|
|
5072
5183
|
async next(fn) {
|
|
5073
5184
|
const event = this.queue.shift();
|
|
5074
|
-
if (event)
|
|
5185
|
+
if (!event) return;
|
|
5186
|
+
const error = await this.run(event, fn);
|
|
5187
|
+
if (error !== void 0) throw error;
|
|
5188
|
+
}
|
|
5189
|
+
/**
|
|
5190
|
+
* The next event whose hash is NOT already being processed, or `undefined`
|
|
5191
|
+
* when every remaining event is blocked on a busy hash.
|
|
5192
|
+
*
|
|
5193
|
+
* Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
|
|
5194
|
+
* `register` for the same query — but the queue enforced it globally, so one
|
|
5195
|
+
* registration RPC at a time was the ceiling for the whole client. An event
|
|
5196
|
+
* for a busy hash keeps its place here (it is skipped, not reordered), so
|
|
5197
|
+
* per-hash ordering is preserved exactly while independent hashes proceed
|
|
5198
|
+
* concurrently.
|
|
5199
|
+
*/
|
|
5200
|
+
takeNext(busy) {
|
|
5201
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
5202
|
+
const event = this.queue[i];
|
|
5203
|
+
if (busy.has(event.payload.hash)) continue;
|
|
5204
|
+
this.queue.splice(i, 1);
|
|
5205
|
+
return event;
|
|
5206
|
+
}
|
|
5207
|
+
}
|
|
5208
|
+
/**
|
|
5209
|
+
* Process one event, applying the re-head / rotate failure policy.
|
|
5210
|
+
*
|
|
5211
|
+
* NEVER rejects: it RETURNS the error instead (`undefined` on success). A
|
|
5212
|
+
* concurrent drain has other work in flight when one event fails, and a
|
|
5213
|
+
* rejection would either take that work down with it or have to be caught at
|
|
5214
|
+
* every call site. `next` reinstates the throwing contract for the serial
|
|
5215
|
+
* callers that still want it.
|
|
5216
|
+
*/
|
|
5217
|
+
async run(event, fn) {
|
|
5218
|
+
try {
|
|
5075
5219
|
await fn(event);
|
|
5076
5220
|
this.failures.delete(event);
|
|
5221
|
+
return;
|
|
5077
5222
|
} catch (error) {
|
|
5078
5223
|
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
5079
5224
|
this.failures.set(event, attempts);
|
|
@@ -5087,7 +5232,7 @@ var DownQueue = class {
|
|
|
5087
5232
|
rotated: starvingOthers,
|
|
5088
5233
|
Category: "sp00ky-client::DownQueue::next"
|
|
5089
5234
|
}, "Failed to process query");
|
|
5090
|
-
|
|
5235
|
+
return error;
|
|
5091
5236
|
}
|
|
5092
5237
|
}
|
|
5093
5238
|
};
|
|
@@ -5317,6 +5462,8 @@ function recordVersionArraysEqual(a, b) {
|
|
|
5317
5462
|
|
|
5318
5463
|
//#endregion
|
|
5319
5464
|
//#region src/modules/sync/engine.ts
|
|
5465
|
+
/** Ids per remote `SELECT * FROM $ids` round trip. */
|
|
5466
|
+
const FETCH_CHUNK = 500;
|
|
5320
5467
|
/**
|
|
5321
5468
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
5322
5469
|
* caching them locally, and ingesting into DBSP.
|
|
@@ -5347,51 +5494,59 @@ var SyncEngine = class {
|
|
|
5347
5494
|
}, "SyncEngine.syncRecords diff");
|
|
5348
5495
|
let stillRemoteIds = [];
|
|
5349
5496
|
if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
5350
|
-
const toFetch = [...added, ...updated]
|
|
5351
|
-
|
|
5352
|
-
|
|
5497
|
+
const toFetch = [...added, ...updated].filter((item) => {
|
|
5498
|
+
const localVersion = this.cache.lookup(encodeRecordId(item.id));
|
|
5499
|
+
return !(localVersion && item.version <= localVersion);
|
|
5500
|
+
});
|
|
5501
|
+
if (toFetch.length === 0) return {
|
|
5353
5502
|
remoteFetchMs: 0,
|
|
5354
5503
|
stillRemoteIds
|
|
5355
5504
|
};
|
|
5356
5505
|
const versionMap = /* @__PURE__ */ new Map();
|
|
5357
5506
|
for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
|
|
5358
|
-
const
|
|
5359
|
-
|
|
5360
|
-
const
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5507
|
+
const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
|
|
5508
|
+
let remoteFetchMs = 0;
|
|
5509
|
+
const remoteResults = [];
|
|
5510
|
+
for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
|
|
5511
|
+
const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
|
|
5512
|
+
const remoteFetchStart = performance.now();
|
|
5513
|
+
const [chunkResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
|
|
5514
|
+
remoteFetchMs += performance.now() - remoteFetchStart;
|
|
5515
|
+
const cacheBatch = [];
|
|
5516
|
+
for (const record of chunkResults ?? []) {
|
|
5517
|
+
if (!record?.id) {
|
|
5518
|
+
this.logger.warn({
|
|
5519
|
+
record,
|
|
5520
|
+
idsToFetch,
|
|
5521
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
5522
|
+
}, "Remote record has no id (possibly deleted). Skipping record");
|
|
5523
|
+
continue;
|
|
5524
|
+
}
|
|
5525
|
+
remoteResults.push(record);
|
|
5526
|
+
const fullId = encodeRecordId(record.id);
|
|
5527
|
+
const table = record.id.table.toString();
|
|
5528
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
5529
|
+
const localVersion = this.cache.lookup(fullId);
|
|
5530
|
+
if (localVersion && version <= localVersion) {
|
|
5531
|
+
this.logger.info({
|
|
5532
|
+
recordId: fullId,
|
|
5533
|
+
version,
|
|
5534
|
+
localVersion,
|
|
5535
|
+
Category: "sp00ky-client::SyncEngine::syncRecords"
|
|
5536
|
+
}, "Local version is higher than remote version. Skipping record");
|
|
5537
|
+
continue;
|
|
5538
|
+
}
|
|
5539
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
5540
|
+
const cleanedRecord = tableSchema ? cleanRecord(tableSchema.columns, record) : record;
|
|
5541
|
+
cacheBatch.push({
|
|
5542
|
+
table,
|
|
5543
|
+
op: addedIds.has(fullId) ? "CREATE" : "UPDATE",
|
|
5544
|
+
record: cleanedRecord,
|
|
5545
|
+
version
|
|
5546
|
+
});
|
|
5384
5547
|
}
|
|
5385
|
-
|
|
5386
|
-
const cleanedRecord = tableSchema ? cleanRecord(tableSchema.columns, record) : record;
|
|
5387
|
-
cacheBatch.push({
|
|
5388
|
-
table,
|
|
5389
|
-
op: isAdded ? "CREATE" : "UPDATE",
|
|
5390
|
-
record: cleanedRecord,
|
|
5391
|
-
version
|
|
5392
|
-
});
|
|
5548
|
+
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
5393
5549
|
}
|
|
5394
|
-
if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
|
|
5395
5550
|
this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
|
|
5396
5551
|
return {
|
|
5397
5552
|
remoteFetchMs,
|
|
@@ -5452,6 +5607,17 @@ var SyncEngine = class {
|
|
|
5452
5607
|
*/
|
|
5453
5608
|
/** Backoff for re-draining a queue that halted on an error. */
|
|
5454
5609
|
const RETRY_BASE_MS = 500;
|
|
5610
|
+
/**
|
|
5611
|
+
* How many down events may be in flight at once.
|
|
5612
|
+
*
|
|
5613
|
+
* The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
|
|
5614
|
+
* time for the WHOLE client. Measured in production that drained at roughly one
|
|
5615
|
+
* event per 8.8s, so a list that registers a query per scrolled-to window took
|
|
5616
|
+
* minutes to fill — the rows were already cached, only the registration lagged.
|
|
5617
|
+
* Ordering is a per-hash requirement, not a global one (see `takeNext`), so
|
|
5618
|
+
* independent hashes can go in parallel. Bounded to stay polite to the SSP.
|
|
5619
|
+
*/
|
|
5620
|
+
const MAX_CONCURRENT_DOWN = 4;
|
|
5455
5621
|
const RETRY_MAX_MS = 15e3;
|
|
5456
5622
|
var SyncScheduler = class {
|
|
5457
5623
|
isSyncingUp = false;
|
|
@@ -5593,14 +5759,35 @@ var SyncScheduler = class {
|
|
|
5593
5759
|
}
|
|
5594
5760
|
this.isSyncingDown = true;
|
|
5595
5761
|
let processedAny = false;
|
|
5762
|
+
const busy = /* @__PURE__ */ new Set();
|
|
5763
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
5764
|
+
let failure;
|
|
5596
5765
|
try {
|
|
5597
|
-
|
|
5598
|
-
if (this.
|
|
5599
|
-
|
|
5600
|
-
|
|
5766
|
+
for (;;) {
|
|
5767
|
+
if (this.paused) break;
|
|
5768
|
+
const yieldToUp = this.upQueue.size > 0;
|
|
5769
|
+
while (failure === void 0 && !yieldToUp && inFlight.size < MAX_CONCURRENT_DOWN) {
|
|
5770
|
+
const event = this.downQueue.takeNext(busy);
|
|
5771
|
+
if (!event) break;
|
|
5772
|
+
const hash = event.payload.hash;
|
|
5773
|
+
busy.add(hash);
|
|
5774
|
+
processedAny = true;
|
|
5775
|
+
const task = this.downQueue.run(event, this.onProcessDown).then((error) => {
|
|
5776
|
+
if (error !== void 0 && failure === void 0) failure = error;
|
|
5777
|
+
}).finally(() => {
|
|
5778
|
+
busy.delete(hash);
|
|
5779
|
+
inFlight.delete(task);
|
|
5780
|
+
});
|
|
5781
|
+
inFlight.add(task);
|
|
5782
|
+
}
|
|
5783
|
+
if (inFlight.size === 0) break;
|
|
5784
|
+
await Promise.race(inFlight);
|
|
5785
|
+
if (yieldToUp && inFlight.size === 0) break;
|
|
5601
5786
|
}
|
|
5787
|
+
if (failure !== void 0) throw failure;
|
|
5602
5788
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
5603
5789
|
this.downRetryAttempt = 0;
|
|
5790
|
+
if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
|
|
5604
5791
|
} catch (error) {
|
|
5605
5792
|
this.onSyncOutcome?.(false, error);
|
|
5606
5793
|
this.scheduleDownRetry();
|
|
@@ -5691,6 +5878,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5691
5878
|
degradeAfterFailures;
|
|
5692
5879
|
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
5693
5880
|
pushTimeoutMs;
|
|
5881
|
+
downTimeoutMs;
|
|
5694
5882
|
consecutiveSyncFailures = 0;
|
|
5695
5883
|
syncHealthStatus = "healthy";
|
|
5696
5884
|
lastSyncErrorKind;
|
|
@@ -5862,6 +6050,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5862
6050
|
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
5863
6051
|
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
5864
6052
|
this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 3e4);
|
|
6053
|
+
this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 3e4);
|
|
5865
6054
|
this.connectionSupervisor = options?.connectionSupervisor;
|
|
5866
6055
|
}
|
|
5867
6056
|
/**
|
|
@@ -5894,14 +6083,37 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5894
6083
|
this.tabRole = role;
|
|
5895
6084
|
this.tabId = tabId;
|
|
5896
6085
|
}
|
|
5897
|
-
/**
|
|
5898
|
-
*
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
6086
|
+
/** In-flight {@link resumeLeaderDuties}, so a second call joins the first
|
|
6087
|
+
* instead of double-draining the outbox. */
|
|
6088
|
+
leaderDutiesInFlight = null;
|
|
6089
|
+
/** Resolves once the in-browser circuit has been primed from the local
|
|
6090
|
+
* store. Every sync diff waits on it: diffing against an empty circuit
|
|
6091
|
+
* classifies the whole working set as missing and re-downloads it. */
|
|
6092
|
+
primeGate = () => Promise.resolve();
|
|
6093
|
+
/** The prime we last waited on. `whenPrimed` hands out one promise per
|
|
6094
|
+
* prime, so a new identity means a new prime (boot, bucket switch) ran. */
|
|
6095
|
+
settledPrime = null;
|
|
6096
|
+
setPrimeGate(gate) {
|
|
6097
|
+
this.primeGate = gate;
|
|
6098
|
+
this.settledPrime = null;
|
|
6099
|
+
}
|
|
6100
|
+
/**
|
|
6101
|
+
* Leader WIRING only, and deliberately synchronous.
|
|
6102
|
+
*
|
|
6103
|
+
* The coordinator publishes the leader role and tells the broker
|
|
6104
|
+
* `leader-ready` the moment the store is adopted, and the broker can mint a
|
|
6105
|
+
* follower's ports on the very next tick. So the follower-message handler
|
|
6106
|
+
* has to be live before this returns, or a mutation forwarded in that window
|
|
6107
|
+
* is dropped. Everything that can block (outbox reload, LIVE restart) moved
|
|
6108
|
+
* to {@link resumeLeaderDuties}: a promotion that waits on the network holds
|
|
6109
|
+
* `leader-ready` back, and a broker whose leader never reports ready serves
|
|
6110
|
+
* no follower ports and re-elects no one, which wedges the whole namespace.
|
|
6111
|
+
*/
|
|
6112
|
+
promoteToLeader(hub) {
|
|
5902
6113
|
this.tabRole = "leader";
|
|
5903
6114
|
this.hub = hub;
|
|
5904
6115
|
this.forwarder = null;
|
|
6116
|
+
this.leaderDutiesInFlight = null;
|
|
5905
6117
|
hub.onFollowerMessage = (tabId, msg) => {
|
|
5906
6118
|
switch (msg.type) {
|
|
5907
6119
|
case "sync-hello": break;
|
|
@@ -5913,7 +6125,17 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5913
6125
|
break;
|
|
5914
6126
|
}
|
|
5915
6127
|
};
|
|
5916
|
-
|
|
6128
|
+
}
|
|
6129
|
+
/** Leader duties: drain the shared outbox, own the single list_ref LIVE,
|
|
6130
|
+
* relay LIVE events and rollbacks to followers. Idempotent for a boot-time
|
|
6131
|
+
* leader; a runtime promotion (failover) reloads the outbox, which now
|
|
6132
|
+
* holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
|
|
6133
|
+
* background off the promotion path, so however long it takes (or if it
|
|
6134
|
+
* never finishes) the tab is already a working leader. */
|
|
6135
|
+
resumeLeaderDuties() {
|
|
6136
|
+
if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
|
|
6137
|
+
const run = (async () => {
|
|
6138
|
+
if (!this.isInit || this.tabRole !== "leader") return;
|
|
5917
6139
|
await this.upQueue.loadFromDatabase();
|
|
5918
6140
|
this.scheduler.syncUp();
|
|
5919
6141
|
if (this.currentUserId || this.anonLiveEnabled) {
|
|
@@ -5921,11 +6143,15 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5921
6143
|
await this.restartRefLiveQuery().catch((err) => {
|
|
5922
6144
|
this.logger.warn({
|
|
5923
6145
|
err,
|
|
5924
|
-
Category: "sp00ky-client::Sp00kySync::
|
|
6146
|
+
Category: "sp00ky-client::Sp00kySync::resumeLeaderDuties"
|
|
5925
6147
|
}, "LIVE restart failed on promotion; poll fallback covers it");
|
|
5926
6148
|
});
|
|
5927
6149
|
}
|
|
5928
|
-
}
|
|
6150
|
+
})();
|
|
6151
|
+
this.leaderDutiesInFlight = run;
|
|
6152
|
+
return run.finally(() => {
|
|
6153
|
+
if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
|
|
6154
|
+
});
|
|
5929
6155
|
}
|
|
5930
6156
|
/** Follower duties: no outbox drain, no LIVE. Mutations forward to the
|
|
5931
6157
|
* leader; everything else (registration, per-query sync, poll) runs
|
|
@@ -5934,6 +6160,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5934
6160
|
this.tabRole = "follower";
|
|
5935
6161
|
this.hub = null;
|
|
5936
6162
|
this.forwarder = forwarder;
|
|
6163
|
+
this.leaderDutiesInFlight = null;
|
|
5937
6164
|
this.killRefLiveQuery();
|
|
5938
6165
|
forwarder.onLeaderMessage = (msg) => {
|
|
5939
6166
|
switch (msg.type) {
|
|
@@ -6544,6 +6771,12 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6544
6771
|
event,
|
|
6545
6772
|
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
6546
6773
|
}, "Processing down event");
|
|
6774
|
+
return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
|
|
6775
|
+
}
|
|
6776
|
+
withDownTimeout(promise, label) {
|
|
6777
|
+
return withTimeout(promise, this.downTimeoutMs, `Down event timed out after ${this.downTimeoutMs}ms (${label})`);
|
|
6778
|
+
}
|
|
6779
|
+
async runDownEvent(event) {
|
|
6547
6780
|
switch (event.type) {
|
|
6548
6781
|
case "register": return this.registerQuery(event.payload.hash);
|
|
6549
6782
|
case "sync": return this.syncQuery(event.payload.hash);
|
|
@@ -6577,6 +6810,16 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6577
6810
|
* means the single resulting UI update lands after this completes.
|
|
6578
6811
|
*/
|
|
6579
6812
|
async runSyncForQuery(hash, diff) {
|
|
6813
|
+
const prime = this.primeGate();
|
|
6814
|
+
if (prime !== this.settledPrime) {
|
|
6815
|
+
await prime;
|
|
6816
|
+
this.settledPrime = prime;
|
|
6817
|
+
const fresh = this.dataModule.getQueryByHash(hash);
|
|
6818
|
+
if (!fresh) return;
|
|
6819
|
+
const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
|
|
6820
|
+
if (!recomputed) return;
|
|
6821
|
+
diff = recomputed;
|
|
6822
|
+
}
|
|
6580
6823
|
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
6581
6824
|
const pendingDeletes = await this.getPendingDeleteIds();
|
|
6582
6825
|
if (pendingDeletes.size > 0) diff = {
|
|
@@ -7094,8 +7337,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7094
7337
|
|
|
7095
7338
|
//#endregion
|
|
7096
7339
|
//#region src/modules/devtools/index.ts
|
|
7097
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7098
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7340
|
+
const CORE_VERSION = "0.0.1-canary.205";
|
|
7341
|
+
const WASM_VERSION = "0.0.1-canary.205";
|
|
7099
7342
|
const SURREAL_VERSION = "3.0.3";
|
|
7100
7343
|
var DevToolsService = class DevToolsService {
|
|
7101
7344
|
eventsHistory = [];
|
|
@@ -7289,7 +7532,7 @@ var DevToolsService = class DevToolsService {
|
|
|
7289
7532
|
const info = this.unwrapInfo(res);
|
|
7290
7533
|
this.localTablesAt = Date.now();
|
|
7291
7534
|
if (info && info.tables) {
|
|
7292
|
-
const names = Object.keys(info.tables);
|
|
7535
|
+
const names = Object.keys(info.tables).filter((n) => n !== "_00_circuit_snapshot");
|
|
7293
7536
|
const changed = names.length !== this.localTables.length || names.some((n, i) => n !== this.localTables[i]);
|
|
7294
7537
|
this.localTables = names;
|
|
7295
7538
|
if (changed) this.notifyDevTools();
|
|
@@ -7819,13 +8062,29 @@ var AuthService = class {
|
|
|
7819
8062
|
|
|
7820
8063
|
//#endregion
|
|
7821
8064
|
//#region src/services/stream-processor/index.ts
|
|
7822
|
-
/**
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
8065
|
+
/** Storage key of the circuit snapshot inside the local store. */
|
|
8066
|
+
const CIRCUIT_SNAPSHOT_KEY = "circuit";
|
|
8067
|
+
/** Bump when the bytes `load_store_state` reads change shape. */
|
|
8068
|
+
const CIRCUIT_SNAPSHOT_FORMAT = 1;
|
|
8069
|
+
/** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
|
|
8070
|
+
* to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
|
|
8071
|
+
* 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
|
|
8072
|
+
const INGEST_CHUNK = 128;
|
|
8073
|
+
/** Ids per `selectByIds` when priming bodies out of the local store. */
|
|
8074
|
+
const PRIME_CHUNK = 256;
|
|
8075
|
+
/** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
|
|
8076
|
+
* through; above this the circuit boots empty as it always did. */
|
|
8077
|
+
const SURREAL_PRIME_ROW_CAP = 2e4;
|
|
8078
|
+
/** Below this many changed rows the checkpoint timer stays quiet. */
|
|
8079
|
+
const CHECKPOINT_MIN_ROWS = 50;
|
|
8080
|
+
/** Stable `table:id` for a row id that may already be a string. */
|
|
8081
|
+
function idString(id) {
|
|
8082
|
+
return typeof id === "string" ? id : encodeRecordId(id);
|
|
8083
|
+
}
|
|
8084
|
+
function chunks(items, size) {
|
|
8085
|
+
const out = [];
|
|
8086
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
8087
|
+
return out;
|
|
7829
8088
|
}
|
|
7830
8089
|
var StreamProcessorService = class {
|
|
7831
8090
|
logger;
|
|
@@ -7838,18 +8097,23 @@ var StreamProcessorService = class {
|
|
|
7838
8097
|
authId: "",
|
|
7839
8098
|
access: ""
|
|
7840
8099
|
};
|
|
7841
|
-
stateKeySuffix = "";
|
|
7842
8100
|
stateGeneration = 0;
|
|
7843
8101
|
persistState = true;
|
|
7844
8102
|
persistCircuit = false;
|
|
7845
8103
|
checkpointMs = 3e4;
|
|
7846
8104
|
checkpointTimer = null;
|
|
7847
8105
|
snapshotDirty = false;
|
|
7848
|
-
|
|
7849
|
-
|
|
8106
|
+
dirtyRows = 0;
|
|
8107
|
+
hideHandler = null;
|
|
8108
|
+
checkpointInFlight = null;
|
|
8109
|
+
projection = true;
|
|
8110
|
+
primed = Promise.resolve();
|
|
8111
|
+
schemaHash = null;
|
|
8112
|
+
widenQueue = Promise.resolve();
|
|
8113
|
+
widenPending = /* @__PURE__ */ new Map();
|
|
8114
|
+
constructor(events, db, logger) {
|
|
7850
8115
|
this.events = events;
|
|
7851
8116
|
this.db = db;
|
|
7852
|
-
this.persistenceClient = persistenceClient;
|
|
7853
8117
|
this.logger = logger.child({ name: "StreamProcessorService" });
|
|
7854
8118
|
}
|
|
7855
8119
|
/**
|
|
@@ -7881,31 +8145,41 @@ var StreamProcessorService = class {
|
|
|
7881
8145
|
for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
|
|
7882
8146
|
}
|
|
7883
8147
|
/**
|
|
7884
|
-
* Ingest a batch of record changes
|
|
7885
|
-
*
|
|
7886
|
-
*
|
|
7887
|
-
*
|
|
7888
|
-
*
|
|
7889
|
-
*
|
|
8148
|
+
* Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
|
|
8149
|
+
* affected query once every record has been ingested. Use this whenever
|
|
8150
|
+
* multiple records land at once (sync fetching N rows, the boot prime).
|
|
8151
|
+
*
|
|
8152
|
+
* The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
|
|
8153
|
+
* circuit step per chunk (a step walks every registered view, so per-record
|
|
8154
|
+
* ingest paid that fixed cost N times), but never the whole batch at once,
|
|
8155
|
+
* because the wasm side has to hold every parsed row of a call at the same
|
|
8156
|
+
* time and wasm32 dlmalloc never returns that peak.
|
|
7890
8157
|
*
|
|
7891
|
-
*
|
|
7892
|
-
*
|
|
7893
|
-
*
|
|
8158
|
+
* Returns the records that were ingested. A chunk that fails is reported
|
|
8159
|
+
* and skipped, not retried (a retry would double-apply whatever the failed
|
|
8160
|
+
* step already committed), and the remaining chunks still run.
|
|
7894
8161
|
*/
|
|
7895
8162
|
ingestMany(records) {
|
|
7896
|
-
if (records.length === 0) return;
|
|
8163
|
+
if (records.length === 0) return [];
|
|
7897
8164
|
if (!this.processor) {
|
|
7898
8165
|
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
|
|
7899
|
-
return;
|
|
8166
|
+
return [];
|
|
7900
8167
|
}
|
|
7901
8168
|
const bulkIngest = this.processor.ingest_many;
|
|
7902
|
-
if (typeof bulkIngest
|
|
7903
|
-
this.
|
|
7904
|
-
count: records.length,
|
|
7905
|
-
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7906
|
-
}, "Ingesting batch into ssp");
|
|
8169
|
+
if (typeof bulkIngest !== "function") {
|
|
8170
|
+
this.beginCoalescing();
|
|
7907
8171
|
try {
|
|
7908
|
-
const
|
|
8172
|
+
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
8173
|
+
} finally {
|
|
8174
|
+
this.flushCoalescing();
|
|
8175
|
+
}
|
|
8176
|
+
return records;
|
|
8177
|
+
}
|
|
8178
|
+
const ingested = [];
|
|
8179
|
+
this.beginCoalescing();
|
|
8180
|
+
try {
|
|
8181
|
+
for (const chunk of chunks(records, INGEST_CHUNK)) try {
|
|
8182
|
+
const items = chunk.map((record) => ({
|
|
7909
8183
|
table: record.table,
|
|
7910
8184
|
op: record.op,
|
|
7911
8185
|
id: record.id,
|
|
@@ -7914,13 +8188,7 @@ var StreamProcessorService = class {
|
|
|
7914
8188
|
const t0 = performance.now();
|
|
7915
8189
|
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
7916
8190
|
const materializationTimeMs = performance.now() - t0;
|
|
7917
|
-
this.
|
|
7918
|
-
count: records.length,
|
|
7919
|
-
rawUpdates: rawUpdates.length,
|
|
7920
|
-
materializationTimeMs,
|
|
7921
|
-
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7922
|
-
}, "Ingesting batch into ssp done");
|
|
7923
|
-
if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
|
|
8191
|
+
if (rawUpdates.length > 0) this.notifyUpdates(rawUpdates.map((u) => ({
|
|
7924
8192
|
queryHash: u.query_id,
|
|
7925
8193
|
localArray: u.result_data,
|
|
7926
8194
|
op: "CREATE",
|
|
@@ -7929,22 +8197,24 @@ var StreamProcessorService = class {
|
|
|
7929
8197
|
circuitStepMs: u.timing_circuit_step_ms,
|
|
7930
8198
|
transformMs: u.timing_transform_ms
|
|
7931
8199
|
})));
|
|
8200
|
+
ingested.push(...chunk);
|
|
7932
8201
|
} catch (e) {
|
|
7933
8202
|
this.logger.error({
|
|
7934
8203
|
error: e,
|
|
7935
|
-
count:
|
|
8204
|
+
count: chunk.length,
|
|
7936
8205
|
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
7937
|
-
}, "Ingesting
|
|
8206
|
+
}, "Ingesting chunk into ssp failed");
|
|
7938
8207
|
}
|
|
7939
|
-
this.markSnapshotDirty();
|
|
7940
|
-
return;
|
|
7941
|
-
}
|
|
7942
|
-
this.beginCoalescing();
|
|
7943
|
-
try {
|
|
7944
|
-
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
7945
8208
|
} finally {
|
|
7946
8209
|
this.flushCoalescing();
|
|
7947
8210
|
}
|
|
8211
|
+
this.logger.debug({
|
|
8212
|
+
count: records.length,
|
|
8213
|
+
ingested: ingested.length,
|
|
8214
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
8215
|
+
}, "Ingested batch into ssp");
|
|
8216
|
+
this.markSnapshotDirty(ingested.length);
|
|
8217
|
+
return ingested;
|
|
7948
8218
|
}
|
|
7949
8219
|
/**
|
|
7950
8220
|
* Open a coalescing window. While open, the per-record stream updates
|
|
@@ -7971,7 +8241,6 @@ var StreamProcessorService = class {
|
|
|
7971
8241
|
const buffered = Array.from(this.batchBuffer.values());
|
|
7972
8242
|
this.batchBuffer.clear();
|
|
7973
8243
|
if (buffered.length > 0) this.dispatchUpdates(buffered);
|
|
7974
|
-
this.markSnapshotDirty();
|
|
7975
8244
|
}
|
|
7976
8245
|
/**
|
|
7977
8246
|
* Initialize the WASM module and processor.
|
|
@@ -7983,7 +8252,7 @@ var StreamProcessorService = class {
|
|
|
7983
8252
|
try {
|
|
7984
8253
|
await init();
|
|
7985
8254
|
this.processor = new Sp00kyProcessor();
|
|
7986
|
-
|
|
8255
|
+
this.applyProjection();
|
|
7987
8256
|
this.isInitialized = true;
|
|
7988
8257
|
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
|
|
7989
8258
|
} catch (e) {
|
|
@@ -7994,21 +8263,16 @@ var StreamProcessorService = class {
|
|
|
7994
8263
|
throw e;
|
|
7995
8264
|
}
|
|
7996
8265
|
}
|
|
7997
|
-
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
7998
|
-
setStateKeySuffix(bucketId) {
|
|
7999
|
-
this.stateKeySuffix = bucketId;
|
|
8000
|
-
}
|
|
8001
|
-
stateKey() {
|
|
8002
|
-
return this.stateKeySuffix ? `_00_stream_processor_state:${this.stateKeySuffix}` : "_00_stream_processor_state";
|
|
8003
|
-
}
|
|
8004
8266
|
/**
|
|
8005
8267
|
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
8006
8268
|
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
8007
8269
|
* views registered with the previous `$auth` context, so neither may survive.
|
|
8008
|
-
* Deliberately
|
|
8009
|
-
*
|
|
8010
|
-
*
|
|
8011
|
-
*
|
|
8270
|
+
* Deliberately loads nothing: the snapshot in the store being swapped away
|
|
8271
|
+
* from belongs to the previous bucket; the caller primes the new bucket's
|
|
8272
|
+
* circuit (`primeFromLocal`) once its store is open, and the DataModule
|
|
8273
|
+
* rebind re-registers every live view against this fresh processor. Caller
|
|
8274
|
+
* must re-seed `setPermissions` afterwards (a fresh circuit default-denies
|
|
8275
|
+
* every table).
|
|
8012
8276
|
*/
|
|
8013
8277
|
async reset() {
|
|
8014
8278
|
if (!this.isInitialized) return;
|
|
@@ -8016,8 +8280,11 @@ var StreamProcessorService = class {
|
|
|
8016
8280
|
this.batching = false;
|
|
8017
8281
|
this.batchBuffer.clear();
|
|
8018
8282
|
this.snapshotDirty = false;
|
|
8283
|
+
this.dirtyRows = 0;
|
|
8284
|
+
this.widenPending.clear();
|
|
8019
8285
|
const previous = this.processor;
|
|
8020
8286
|
this.processor = new Sp00kyProcessor();
|
|
8287
|
+
this.applyProjection();
|
|
8021
8288
|
this.freeProcessor(previous);
|
|
8022
8289
|
this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
|
|
8023
8290
|
}
|
|
@@ -8057,9 +8324,9 @@ var StreamProcessorService = class {
|
|
|
8057
8324
|
if (!enabled) this.stopCheckpoints();
|
|
8058
8325
|
}
|
|
8059
8326
|
/**
|
|
8060
|
-
*
|
|
8061
|
-
*
|
|
8062
|
-
*
|
|
8327
|
+
* Snapshot persistence (`persistCircuit`). When on, the circuit's store is
|
|
8328
|
+
* written to the local store on a checkpoint interval and when the page
|
|
8329
|
+
* goes hidden, and restored by {@link primeFromLocal} on the next boot.
|
|
8063
8330
|
*/
|
|
8064
8331
|
configureCircuitPersistence(enabled, checkpointMs) {
|
|
8065
8332
|
this.persistCircuit = enabled;
|
|
@@ -8067,59 +8334,291 @@ var StreamProcessorService = class {
|
|
|
8067
8334
|
if (!enabled) this.stopCheckpoints();
|
|
8068
8335
|
}
|
|
8069
8336
|
/**
|
|
8070
|
-
*
|
|
8071
|
-
*
|
|
8072
|
-
|
|
8337
|
+
* Field projection (`circuitProjection`, default on). Takes effect on the
|
|
8338
|
+
* next processor (`init`/`reset`) and on rows written after that.
|
|
8339
|
+
*/
|
|
8340
|
+
configureProjection(enabled) {
|
|
8341
|
+
this.projection = enabled;
|
|
8342
|
+
this.applyProjection();
|
|
8343
|
+
}
|
|
8344
|
+
applyProjection() {
|
|
8345
|
+
if (!this.processor || typeof this.processor.set_projection !== "function") return;
|
|
8346
|
+
try {
|
|
8347
|
+
this.processor.set_projection(this.projection);
|
|
8348
|
+
} catch (e) {
|
|
8349
|
+
this.logger.warn({
|
|
8350
|
+
error: e,
|
|
8351
|
+
Category: "sp00ky-client::StreamProcessorService::applyProjection"
|
|
8352
|
+
}, "set_projection failed");
|
|
8353
|
+
}
|
|
8354
|
+
}
|
|
8355
|
+
/** Resolves once the boot-time prime has finished (or was skipped). */
|
|
8356
|
+
whenPrimed() {
|
|
8357
|
+
return this.primed;
|
|
8358
|
+
}
|
|
8359
|
+
/**
|
|
8360
|
+
* Fill the circuit from the LOCAL store, in the background.
|
|
8361
|
+
*
|
|
8362
|
+
* With a usable snapshot: install it under whatever views have registered
|
|
8363
|
+
* meanwhile (`load_store_state` re-primes them), then `reconcile` each table
|
|
8364
|
+
* against the store's `(id, rv)` list so rows deleted since the checkpoint
|
|
8365
|
+
* are stepped out and only rows added or changed since are read back and
|
|
8366
|
+
* ingested. Without one: read every row and ingest it, chunked.
|
|
8367
|
+
*
|
|
8368
|
+
* Either way the circuit ends up equal to the local store without touching
|
|
8369
|
+
* the network, so the first sync diff is a real delta rather than "fetch
|
|
8370
|
+
* everything". The returned promise never rejects; `whenPrimed` gates on it.
|
|
8371
|
+
*/
|
|
8372
|
+
primeFromLocal(ctx) {
|
|
8373
|
+
this.schemaHash = ctx.schemaHash;
|
|
8374
|
+
const run = this.runPrime(ctx).catch((e) => {
|
|
8375
|
+
this.logger.warn({
|
|
8376
|
+
error: e,
|
|
8377
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8378
|
+
}, "Circuit prime failed; the circuit fills from sync instead");
|
|
8379
|
+
});
|
|
8380
|
+
this.primed = run;
|
|
8381
|
+
return run;
|
|
8382
|
+
}
|
|
8383
|
+
async runPrime(ctx) {
|
|
8384
|
+
const processor = this.processor;
|
|
8385
|
+
if (!processor || typeof this.db.scanVersions !== "function") return;
|
|
8386
|
+
const generation = this.stateGeneration;
|
|
8387
|
+
const epoch = this.db.epoch;
|
|
8388
|
+
const alive = () => this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
|
|
8389
|
+
const t0 = performance.now();
|
|
8390
|
+
let restored = false;
|
|
8391
|
+
if (this.persistCircuit && typeof this.db.getSnapshot === "function" && typeof processor.load_store_state === "function") {
|
|
8392
|
+
const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
|
|
8393
|
+
if (!alive()) return;
|
|
8394
|
+
if (snapshot) {
|
|
8395
|
+
const { meta } = snapshot;
|
|
8396
|
+
if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) this.logger.info({
|
|
8397
|
+
meta,
|
|
8398
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8399
|
+
}, "Circuit snapshot is from another format or schema; priming from rows");
|
|
8400
|
+
else try {
|
|
8401
|
+
const tLoad = performance.now();
|
|
8402
|
+
const updates = processor.load_store_state(snapshot.bytes);
|
|
8403
|
+
this.dispatchWasmUpdates(updates);
|
|
8404
|
+
restored = true;
|
|
8405
|
+
this.logger.info({
|
|
8406
|
+
bytes: snapshot.bytes.byteLength,
|
|
8407
|
+
views: updates.length,
|
|
8408
|
+
loadMs: Math.round(performance.now() - tLoad),
|
|
8409
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8410
|
+
}, "Circuit snapshot restored");
|
|
8411
|
+
} catch (e) {
|
|
8412
|
+
this.logger.warn({
|
|
8413
|
+
error: e,
|
|
8414
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8415
|
+
}, "Circuit snapshot unreadable; priming from rows");
|
|
8416
|
+
}
|
|
8417
|
+
}
|
|
8418
|
+
}
|
|
8419
|
+
const versions = await this.db.scanVersions(ctx.tables);
|
|
8420
|
+
if (!alive()) return;
|
|
8421
|
+
const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
|
|
8422
|
+
if (!restored && this.db.engineKind !== "sqlite" && total > SURREAL_PRIME_ROW_CAP) {
|
|
8423
|
+
this.logger.warn({
|
|
8424
|
+
total,
|
|
8425
|
+
cap: SURREAL_PRIME_ROW_CAP,
|
|
8426
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8427
|
+
}, "Too many cached rows to prime through the main-thread engine; circuit fills from sync");
|
|
8428
|
+
return;
|
|
8429
|
+
}
|
|
8430
|
+
let ingested = 0;
|
|
8431
|
+
let fetched = 0;
|
|
8432
|
+
let deleted = 0;
|
|
8433
|
+
for (const table of ctx.tables) {
|
|
8434
|
+
const entries = versions[table] ?? [];
|
|
8435
|
+
let toFetch;
|
|
8436
|
+
if (restored && typeof processor.reconcile === "function") {
|
|
8437
|
+
const result = processor.reconcile(table, entries);
|
|
8438
|
+
this.dispatchWasmUpdates(result.updates);
|
|
8439
|
+
deleted += result.deleted;
|
|
8440
|
+
toFetch = result.fetch;
|
|
8441
|
+
} else toFetch = entries.map(([id]) => id);
|
|
8442
|
+
for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
|
|
8443
|
+
const rows = await this.db.selectByIds(table, chunk);
|
|
8444
|
+
if (!alive()) return;
|
|
8445
|
+
fetched += rows.length;
|
|
8446
|
+
ingested += this.ingestMany(rows.map((row) => ({
|
|
8447
|
+
table,
|
|
8448
|
+
op: "CREATE",
|
|
8449
|
+
id: idString(row.id),
|
|
8450
|
+
record: row
|
|
8451
|
+
}))).length;
|
|
8452
|
+
}
|
|
8453
|
+
if (entries.length > 0) ctx.onVersions?.(table, entries.filter(([id]) => !ctx.pendingIds.has(id)));
|
|
8454
|
+
}
|
|
8455
|
+
if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
|
|
8456
|
+
this.logger.info({
|
|
8457
|
+
restored,
|
|
8458
|
+
rows: total,
|
|
8459
|
+
fetched,
|
|
8460
|
+
ingested,
|
|
8461
|
+
deleted,
|
|
8462
|
+
ms: Math.round(performance.now() - t0),
|
|
8463
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
8464
|
+
}, "Circuit primed from the local store");
|
|
8465
|
+
}
|
|
8466
|
+
/** Publish wasm updates produced outside an ingest (restore, reconcile). */
|
|
8467
|
+
dispatchWasmUpdates(updates) {
|
|
8468
|
+
if (!updates || updates.length === 0) return;
|
|
8469
|
+
this.dispatchUpdates(updates.map((u) => ({
|
|
8470
|
+
queryHash: u.query_id,
|
|
8471
|
+
localArray: u.result_data,
|
|
8472
|
+
op: "CREATE"
|
|
8473
|
+
})));
|
|
8474
|
+
}
|
|
8475
|
+
/**
|
|
8476
|
+
* Record that the circuit changed by `rows` rows. Cheap; the snapshot is
|
|
8477
|
+
* deferred to the checkpoint timer and skipped entirely when `persistCircuit`
|
|
8478
|
+
* is off.
|
|
8073
8479
|
*/
|
|
8074
|
-
markSnapshotDirty() {
|
|
8480
|
+
markSnapshotDirty(rows = 1) {
|
|
8075
8481
|
if (!this.persistCircuit || !this.persistState) return;
|
|
8076
8482
|
this.snapshotDirty = true;
|
|
8483
|
+
this.dirtyRows += rows;
|
|
8077
8484
|
this.startCheckpoints();
|
|
8078
8485
|
}
|
|
8079
8486
|
startCheckpoints() {
|
|
8080
8487
|
if (this.checkpointTimer) return;
|
|
8081
8488
|
this.checkpointTimer = setInterval(() => {
|
|
8082
|
-
if (!this.snapshotDirty) return;
|
|
8083
|
-
this.
|
|
8084
|
-
this.saveState();
|
|
8489
|
+
if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
|
|
8490
|
+
this.checkpoint("interval");
|
|
8085
8491
|
}, this.checkpointMs);
|
|
8086
|
-
if (typeof window !== "undefined" && !this.
|
|
8087
|
-
this.
|
|
8492
|
+
if (typeof window !== "undefined" && !this.hideHandler) {
|
|
8493
|
+
this.hideHandler = () => {
|
|
8088
8494
|
if (!this.snapshotDirty) return;
|
|
8089
|
-
|
|
8090
|
-
this.
|
|
8495
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
|
|
8496
|
+
this.checkpoint("hidden");
|
|
8091
8497
|
};
|
|
8092
|
-
window.addEventListener("
|
|
8498
|
+
window.addEventListener("visibilitychange", this.hideHandler);
|
|
8499
|
+
window.addEventListener("pagehide", this.hideHandler);
|
|
8093
8500
|
}
|
|
8094
8501
|
}
|
|
8095
|
-
/** Stop checkpointing and drop the
|
|
8502
|
+
/** Stop checkpointing and drop the visibility listeners. */
|
|
8096
8503
|
stopCheckpoints() {
|
|
8097
8504
|
if (this.checkpointTimer) {
|
|
8098
8505
|
clearInterval(this.checkpointTimer);
|
|
8099
8506
|
this.checkpointTimer = null;
|
|
8100
8507
|
}
|
|
8101
|
-
if (this.
|
|
8102
|
-
|
|
8508
|
+
if (this.hideHandler && typeof window !== "undefined") {
|
|
8509
|
+
window.removeEventListener("visibilitychange", this.hideHandler);
|
|
8510
|
+
window.removeEventListener("pagehide", this.hideHandler);
|
|
8511
|
+
}
|
|
8512
|
+
this.hideHandler = null;
|
|
8103
8513
|
this.snapshotDirty = false;
|
|
8514
|
+
this.dirtyRows = 0;
|
|
8515
|
+
}
|
|
8516
|
+
/**
|
|
8517
|
+
* Write the circuit's store to the local store as a snapshot. Compacts the
|
|
8518
|
+
* row arena first when dead bytes outweigh live ones. Serialised: a second
|
|
8519
|
+
* call while one is in flight joins it. No-op unless persistence is on, this
|
|
8520
|
+
* tab owns the store, and the engine can hold a snapshot.
|
|
8521
|
+
*/
|
|
8522
|
+
checkpoint(reason = "manual") {
|
|
8523
|
+
if (this.checkpointInFlight) return this.checkpointInFlight;
|
|
8524
|
+
const run = this.runCheckpoint(reason).finally(() => {
|
|
8525
|
+
this.checkpointInFlight = null;
|
|
8526
|
+
});
|
|
8527
|
+
this.checkpointInFlight = run;
|
|
8528
|
+
return run;
|
|
8104
8529
|
}
|
|
8105
|
-
async
|
|
8106
|
-
|
|
8530
|
+
async runCheckpoint(reason) {
|
|
8531
|
+
const processor = this.processor;
|
|
8532
|
+
if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
|
|
8533
|
+
if (typeof this.db.putSnapshot !== "function" || typeof processor.save_store_state !== "function") return;
|
|
8534
|
+
const generation = this.stateGeneration;
|
|
8535
|
+
const epoch = this.db.epoch;
|
|
8536
|
+
const rows = this.dirtyRows;
|
|
8537
|
+
this.snapshotDirty = false;
|
|
8538
|
+
this.dirtyRows = 0;
|
|
8107
8539
|
try {
|
|
8108
|
-
const
|
|
8109
|
-
|
|
8110
|
-
if (
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8540
|
+
const t0 = performance.now();
|
|
8541
|
+
let reclaimed = 0;
|
|
8542
|
+
if (typeof processor.compact === "function" && typeof processor.dead_bytes === "function" && typeof processor.live_bytes === "function") {
|
|
8543
|
+
const dead = processor.dead_bytes();
|
|
8544
|
+
if (dead > 1e6 && dead > processor.live_bytes()) reclaimed = processor.compact();
|
|
8545
|
+
}
|
|
8546
|
+
const bytes = processor.save_store_state();
|
|
8547
|
+
const meta = {
|
|
8548
|
+
formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
|
|
8549
|
+
schemaHash: this.schemaHash,
|
|
8550
|
+
savedAt: Date.now(),
|
|
8551
|
+
maxRv: typeof processor.max_row_versions === "function" ? processor.max_row_versions() : void 0
|
|
8552
|
+
};
|
|
8553
|
+
if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
8554
|
+
await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
|
|
8555
|
+
this.logger.info({
|
|
8556
|
+
reason,
|
|
8557
|
+
rows,
|
|
8558
|
+
bytes: bytes.byteLength,
|
|
8559
|
+
reclaimed,
|
|
8560
|
+
ms: Math.round(performance.now() - t0),
|
|
8561
|
+
Category: "sp00ky-client::StreamProcessorService::checkpoint"
|
|
8562
|
+
}, "Circuit snapshot written");
|
|
8118
8563
|
} catch (e) {
|
|
8119
|
-
this.logger.
|
|
8564
|
+
this.logger.warn({
|
|
8120
8565
|
error: e,
|
|
8121
|
-
|
|
8122
|
-
|
|
8566
|
+
reason,
|
|
8567
|
+
Category: "sp00ky-client::StreamProcessorService::checkpoint"
|
|
8568
|
+
}, "Circuit snapshot failed");
|
|
8569
|
+
}
|
|
8570
|
+
}
|
|
8571
|
+
/**
|
|
8572
|
+
* Projection widening: a newly registered view evaluates fields that rows
|
|
8573
|
+
* already in the circuit were stored without. Merge just those fields in,
|
|
8574
|
+
* table by table, from the local store. The view registered against what
|
|
8575
|
+
* was present and converges as the merges step through.
|
|
8576
|
+
*/
|
|
8577
|
+
scheduleWiden(missing) {
|
|
8578
|
+
for (const [table, fields] of Object.entries(missing)) {
|
|
8579
|
+
const set = this.widenPending.get(table) ?? /* @__PURE__ */ new Set();
|
|
8580
|
+
for (const f of fields) set.add(f);
|
|
8581
|
+
this.widenPending.set(table, set);
|
|
8582
|
+
}
|
|
8583
|
+
this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
|
|
8584
|
+
this.logger.warn({
|
|
8585
|
+
error: e,
|
|
8586
|
+
Category: "sp00ky-client::StreamProcessorService::widen"
|
|
8587
|
+
}, "Projection widening failed");
|
|
8588
|
+
});
|
|
8589
|
+
}
|
|
8590
|
+
async runWiden() {
|
|
8591
|
+
const processor = this.processor;
|
|
8592
|
+
if (!processor || typeof this.db.scanVersions !== "function") {
|
|
8593
|
+
this.widenPending.clear();
|
|
8594
|
+
return;
|
|
8595
|
+
}
|
|
8596
|
+
const generation = this.stateGeneration;
|
|
8597
|
+
const epoch = this.db.epoch;
|
|
8598
|
+
while (this.widenPending.size > 0) {
|
|
8599
|
+
const [table, fields] = this.widenPending.entries().next().value;
|
|
8600
|
+
this.widenPending.delete(table);
|
|
8601
|
+
const select = Array.from(fields);
|
|
8602
|
+
const versions = await this.db.scanVersions([table]);
|
|
8603
|
+
if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
8604
|
+
const ids = (versions[table] ?? []).map(([id]) => id);
|
|
8605
|
+
let merged = 0;
|
|
8606
|
+
for (const chunk of chunks(ids, PRIME_CHUNK)) {
|
|
8607
|
+
const rows = await this.db.selectByIds(table, chunk, { select });
|
|
8608
|
+
if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
8609
|
+
merged += this.ingestMany(rows.map((row) => ({
|
|
8610
|
+
table,
|
|
8611
|
+
op: "MERGE",
|
|
8612
|
+
id: idString(row.id),
|
|
8613
|
+
record: row
|
|
8614
|
+
}))).length;
|
|
8615
|
+
}
|
|
8616
|
+
this.logger.info({
|
|
8617
|
+
table,
|
|
8618
|
+
fields: select,
|
|
8619
|
+
merged,
|
|
8620
|
+
Category: "sp00ky-client::StreamProcessorService::widen"
|
|
8621
|
+
}, "Widened projected rows with newly evaluated fields");
|
|
8123
8622
|
}
|
|
8124
8623
|
}
|
|
8125
8624
|
/**
|
|
@@ -8160,25 +8659,6 @@ var StreamProcessorService = class {
|
|
|
8160
8659
|
Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
|
|
8161
8660
|
}, "Session auth context updated");
|
|
8162
8661
|
}
|
|
8163
|
-
async saveState() {
|
|
8164
|
-
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
8165
|
-
const generation = this.stateGeneration;
|
|
8166
|
-
try {
|
|
8167
|
-
if (typeof this.processor.save_state === "function") {
|
|
8168
|
-
const state = this.processor.save_state();
|
|
8169
|
-
if (generation !== this.stateGeneration) return;
|
|
8170
|
-
if (state) {
|
|
8171
|
-
await this.persistenceClient.set(this.stateKey(), state);
|
|
8172
|
-
this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
|
|
8173
|
-
}
|
|
8174
|
-
}
|
|
8175
|
-
} catch (e) {
|
|
8176
|
-
this.logger.error({
|
|
8177
|
-
error: e,
|
|
8178
|
-
Category: "sp00ky-client::StreamProcessorService::saveState"
|
|
8179
|
-
}, "Failed to save state");
|
|
8180
|
-
}
|
|
8181
|
-
}
|
|
8182
8662
|
/**
|
|
8183
8663
|
* Ingest a record change into the processor.
|
|
8184
8664
|
* Emits 'stream_update' event if materialized views are affected.
|
|
@@ -8212,7 +8692,7 @@ var StreamProcessorService = class {
|
|
|
8212
8692
|
const updates = rawUpdates.map((u) => ({
|
|
8213
8693
|
queryHash: u.query_id,
|
|
8214
8694
|
localArray: u.result_data,
|
|
8215
|
-
op,
|
|
8695
|
+
op: op === "MERGE" ? "UPDATE" : op,
|
|
8216
8696
|
materializationTimeMs,
|
|
8217
8697
|
storeApplyMs: u.timing_store_apply_ms,
|
|
8218
8698
|
circuitStepMs: u.timing_circuit_step_ms,
|
|
@@ -8273,7 +8753,7 @@ var StreamProcessorService = class {
|
|
|
8273
8753
|
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
|
|
8274
8754
|
}
|
|
8275
8755
|
};
|
|
8276
|
-
this.
|
|
8756
|
+
if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) this.scheduleWiden(initialUpdate.missing_fields);
|
|
8277
8757
|
this.logger.debug({
|
|
8278
8758
|
queryHash: queryPlan.queryHash,
|
|
8279
8759
|
surql: queryPlan.surql,
|
|
@@ -8296,7 +8776,6 @@ var StreamProcessorService = class {
|
|
|
8296
8776
|
if (!this.processor) return;
|
|
8297
8777
|
try {
|
|
8298
8778
|
this.processor.unregister_view(queryHash);
|
|
8299
|
-
this.markSnapshotDirty();
|
|
8300
8779
|
} catch (e) {
|
|
8301
8780
|
this.logger.error({
|
|
8302
8781
|
error: e,
|
|
@@ -8427,6 +8906,16 @@ var CacheModule = class {
|
|
|
8427
8906
|
lookup(recordId) {
|
|
8428
8907
|
return this.versionLookups[recordId] ?? 0;
|
|
8429
8908
|
}
|
|
8909
|
+
/**
|
|
8910
|
+
* Seed the version memo from rows the circuit was primed with out of the
|
|
8911
|
+
* local store, so the first post-reload sync diff does not re-download
|
|
8912
|
+
* bodies the browser already has. Only rows the prime actually put into the
|
|
8913
|
+
* circuit belong here: a memo entry with no circuit row would make the diff
|
|
8914
|
+
* flag the id forever while nothing ever fetches it.
|
|
8915
|
+
*/
|
|
8916
|
+
primeVersions(entries) {
|
|
8917
|
+
for (const [id, rv] of entries) if (rv > (this.versionLookups[id] ?? 0)) this.versionLookups[id] = rv;
|
|
8918
|
+
}
|
|
8430
8919
|
/** Drop the version cache on a bucket switch — a stale version would make
|
|
8431
8920
|
* the sync diff skip fetching a body the new bucket legitimately needs. */
|
|
8432
8921
|
clearVersionLookups() {
|
|
@@ -8475,9 +8964,10 @@ var CacheModule = class {
|
|
|
8475
8964
|
await this.local.execute(query, params, { epoch });
|
|
8476
8965
|
}
|
|
8477
8966
|
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
8967
|
+
const versionOf = /* @__PURE__ */ new Map();
|
|
8478
8968
|
const bulk = populatedRecords.map((record) => {
|
|
8479
8969
|
const recordId = encodeRecordId(record.record.id);
|
|
8480
|
-
|
|
8970
|
+
versionOf.set(recordId, record.version);
|
|
8481
8971
|
return {
|
|
8482
8972
|
table: record.table,
|
|
8483
8973
|
op: record.op,
|
|
@@ -8485,8 +8975,9 @@ var CacheModule = class {
|
|
|
8485
8975
|
record: record.record
|
|
8486
8976
|
};
|
|
8487
8977
|
});
|
|
8488
|
-
this.streamProcessor.ingestMany(bulk);
|
|
8489
|
-
this.
|
|
8978
|
+
const ingested = this.streamProcessor.ingestMany(bulk);
|
|
8979
|
+
for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
|
|
8980
|
+
if (ingested.length > 0) this.ingestRelay?.(ingested);
|
|
8490
8981
|
this.logger.debug({
|
|
8491
8982
|
count: records.length,
|
|
8492
8983
|
Category: "sp00ky-client::CacheModule::saveBatch"
|
|
@@ -8521,7 +9012,12 @@ var CacheModule = class {
|
|
|
8521
9012
|
if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) }, { epoch });
|
|
8522
9013
|
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
8523
9014
|
delete this.versionLookups[id];
|
|
8524
|
-
this.streamProcessor.
|
|
9015
|
+
this.streamProcessor.ingestMany([{
|
|
9016
|
+
table,
|
|
9017
|
+
op: "DELETE",
|
|
9018
|
+
id,
|
|
9019
|
+
record: recordData
|
|
9020
|
+
}]);
|
|
8525
9021
|
this.ingestRelay?.([{
|
|
8526
9022
|
table,
|
|
8527
9023
|
op: "DELETE",
|
|
@@ -10111,6 +10607,10 @@ var TabsCoordinator = class {
|
|
|
10111
10607
|
leadershipId = 0;
|
|
10112
10608
|
leaderTabId = null;
|
|
10113
10609
|
brokerRole = "pending";
|
|
10610
|
+
/** Wall time from `become-leader` to `leader-ready` on the last promotion.
|
|
10611
|
+
* Reported in the DevTools role-change event: a slow store adopt (OPFS pool
|
|
10612
|
+
* contention after the previous leader died) shows up here as seconds. */
|
|
10613
|
+
lastPromotionMs = null;
|
|
10114
10614
|
fingerprint;
|
|
10115
10615
|
bucketId;
|
|
10116
10616
|
broker;
|
|
@@ -10161,6 +10661,7 @@ var TabsCoordinator = class {
|
|
|
10161
10661
|
this.deps.logger.info({
|
|
10162
10662
|
role,
|
|
10163
10663
|
leadershipId: this.leadershipId,
|
|
10664
|
+
promotionMs: role === "leader" ? this.lastPromotionMs : void 0,
|
|
10164
10665
|
Category: "sp00ky-client::TabsCoordinator"
|
|
10165
10666
|
}, "Tab role changed");
|
|
10166
10667
|
for (const cb of this.roleListeners) cb(role);
|
|
@@ -10237,6 +10738,7 @@ var TabsCoordinator = class {
|
|
|
10237
10738
|
if (this.closed) return;
|
|
10238
10739
|
if (msg.leadershipId <= this.leadershipId && !msg.resumeHeld) return;
|
|
10239
10740
|
const previousRole = this.role;
|
|
10741
|
+
const startedAt = Date.now();
|
|
10240
10742
|
try {
|
|
10241
10743
|
if (previousRole === "follower") this.teardownFollower("promoted");
|
|
10242
10744
|
this.leadershipId = msg.leadershipId;
|
|
@@ -10258,7 +10760,8 @@ var TabsCoordinator = class {
|
|
|
10258
10760
|
resumeHeld: msg.resumeHeld
|
|
10259
10761
|
});
|
|
10260
10762
|
this.hub = new LeaderSyncHub(msg.leadershipId, this.deps.logger);
|
|
10261
|
-
|
|
10763
|
+
this.deps.hooks.becomeSyncLeader(this.hub);
|
|
10764
|
+
this.lastPromotionMs = Date.now() - startedAt;
|
|
10262
10765
|
this.setRole("leader");
|
|
10263
10766
|
this.broker.send({
|
|
10264
10767
|
type: "leader-ready",
|
|
@@ -10266,6 +10769,12 @@ var TabsCoordinator = class {
|
|
|
10266
10769
|
bucketId: this.bucketId,
|
|
10267
10770
|
leadershipId: msg.leadershipId
|
|
10268
10771
|
});
|
|
10772
|
+
this.deps.hooks.resumeSyncLeaderDuties().catch((e) => {
|
|
10773
|
+
this.deps.logger.error({
|
|
10774
|
+
err: e,
|
|
10775
|
+
Category: "sp00ky-client::TabsCoordinator"
|
|
10776
|
+
}, "Leader duties failed after promotion");
|
|
10777
|
+
});
|
|
10269
10778
|
} catch (e) {
|
|
10270
10779
|
const reason = e instanceof Error ? e.message : String(e);
|
|
10271
10780
|
this.deps.logger.error({
|
|
@@ -11704,8 +12213,9 @@ var Sp00kyClient = class {
|
|
|
11704
12213
|
else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
|
|
11705
12214
|
else this.persistenceClient = config.persistenceClient;
|
|
11706
12215
|
this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
|
|
11707
|
-
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local,
|
|
11708
|
-
this.streamProcessor.configureCircuitPersistence(config.persistCircuit ??
|
|
12216
|
+
this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, logger);
|
|
12217
|
+
this.streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === "sqlite", config.circuitCheckpointMs);
|
|
12218
|
+
this.streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
11709
12219
|
this.migrator = new LocalMigrator(this.local, logger);
|
|
11710
12220
|
this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
|
|
11711
12221
|
this.dataModule.onStreamUpdate(update);
|
|
@@ -11718,8 +12228,10 @@ var Sp00kyClient = class {
|
|
|
11718
12228
|
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
11719
12229
|
degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
|
|
11720
12230
|
pushTimeoutMs: this.config.pushTimeoutMs,
|
|
12231
|
+
downTimeoutMs: this.config.downTimeoutMs,
|
|
11721
12232
|
connectionSupervisor: this.connectionSupervisor
|
|
11722
12233
|
});
|
|
12234
|
+
this.sync.setPrimeGate(() => this.streamProcessor.whenPrimed());
|
|
11723
12235
|
this.featureFlags = new FeatureFlagModule({
|
|
11724
12236
|
dataModule: this.dataModule,
|
|
11725
12237
|
sync: this.sync,
|
|
@@ -11772,7 +12284,7 @@ var Sp00kyClient = class {
|
|
|
11772
12284
|
return new TabsCoordinator({
|
|
11773
12285
|
tabId,
|
|
11774
12286
|
fingerprint: computeTabsFingerprint({
|
|
11775
|
-
coreVersion: "0.0.1-canary.
|
|
12287
|
+
coreVersion: "0.0.1-canary.205",
|
|
11776
12288
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11777
12289
|
endpoint: this.config.database.endpoint ?? "",
|
|
11778
12290
|
namespace: this.config.database.namespace,
|
|
@@ -11789,13 +12301,14 @@ var Sp00kyClient = class {
|
|
|
11789
12301
|
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
11790
12302
|
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
11791
12303
|
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
11792
|
-
becomeSyncLeader:
|
|
12304
|
+
becomeSyncLeader: (hub) => {
|
|
11793
12305
|
this.streamProcessor.setPersistenceEnabled(true);
|
|
11794
12306
|
this.cache.setIngestRelay((tuples) => hub.relayIngest(tuples));
|
|
11795
12307
|
this.sync.setTabContext("leader", tabId);
|
|
11796
12308
|
this.dataModule.setTabId(tabId);
|
|
11797
|
-
|
|
12309
|
+
this.sync.promoteToLeader(hub);
|
|
11798
12310
|
},
|
|
12311
|
+
resumeSyncLeaderDuties: () => this.sync.resumeLeaderDuties(),
|
|
11799
12312
|
becomeSyncFollower: (forwarder) => {
|
|
11800
12313
|
this.streamProcessor.setPersistenceEnabled(false);
|
|
11801
12314
|
this.cache.setIngestRelay(null);
|
|
@@ -11889,6 +12402,13 @@ var Sp00kyClient = class {
|
|
|
11889
12402
|
if (this.tabsCoordinator) {
|
|
11890
12403
|
this.tabsCoordinator.onRoleChange((role) => {
|
|
11891
12404
|
this.sharedActive = role !== "solo";
|
|
12405
|
+
this.devTools.logEvent("TABS_ROLE_CHANGED", {
|
|
12406
|
+
role,
|
|
12407
|
+
tabId: this.tabsCoordinator?.tabId,
|
|
12408
|
+
leadershipId: this.tabsCoordinator?.leadershipId,
|
|
12409
|
+
leaderTabId: this.tabsCoordinator?.leaderTabId,
|
|
12410
|
+
promotionMs: this.tabsCoordinator?.lastPromotionMs
|
|
12411
|
+
});
|
|
11892
12412
|
});
|
|
11893
12413
|
try {
|
|
11894
12414
|
const role = await this.tabsCoordinator.start(bootBucket);
|
|
@@ -11926,9 +12446,9 @@ var Sp00kyClient = class {
|
|
|
11926
12446
|
}, "Blob cache failed to start; bucket files will not be cached locally");
|
|
11927
12447
|
}
|
|
11928
12448
|
})();
|
|
11929
|
-
this.streamProcessor.setStateKeySuffix(bootBucket);
|
|
11930
12449
|
await this.streamProcessor.init();
|
|
11931
12450
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
12451
|
+
this.primeCircuit();
|
|
11932
12452
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
11933
12453
|
const restoredUserId = await this.auth.restoreSessionFromToken();
|
|
11934
12454
|
const sessionId = this.mintSessionSalt();
|
|
@@ -12097,10 +12617,10 @@ var Sp00kyClient = class {
|
|
|
12097
12617
|
Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
|
|
12098
12618
|
}, "Blob cache bucket switch failed");
|
|
12099
12619
|
}
|
|
12100
|
-
this.streamProcessor.setStateKeySuffix(target);
|
|
12101
12620
|
await this.streamProcessor.reset();
|
|
12102
12621
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
12103
12622
|
this.cache.clearVersionLookups();
|
|
12623
|
+
this.primeCircuit();
|
|
12104
12624
|
this.preloadedHashes.clear();
|
|
12105
12625
|
} finally {
|
|
12106
12626
|
reopen();
|
|
@@ -12132,6 +12652,7 @@ var Sp00kyClient = class {
|
|
|
12132
12652
|
this.crdtManager.closeAll();
|
|
12133
12653
|
this.crdtManager.dispose();
|
|
12134
12654
|
await this.blobs.close().catch(() => {});
|
|
12655
|
+
await this.streamProcessor.checkpoint("close").catch(() => {});
|
|
12135
12656
|
if (this.tabsCoordinator) await this.tabsCoordinator.stop();
|
|
12136
12657
|
await this.local.close();
|
|
12137
12658
|
await this.remote.close();
|
|
@@ -12419,6 +12940,31 @@ var Sp00kyClient = class {
|
|
|
12419
12940
|
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
12420
12941
|
* during boot.
|
|
12421
12942
|
*/
|
|
12943
|
+
/**
|
|
12944
|
+
* Prime the in-browser circuit from the local store. Builds the context the
|
|
12945
|
+
* stream processor needs: every synced table (the app schema plus the
|
|
12946
|
+
* server-written meta tables that sync down), a schema hash so a snapshot
|
|
12947
|
+
* projected under another schema is not trusted, and the ids whose local
|
|
12948
|
+
* `_00_rv` was bumped by an unsettled mutation.
|
|
12949
|
+
*/
|
|
12950
|
+
async primeCircuit() {
|
|
12951
|
+
const tables = [
|
|
12952
|
+
...this.config.schema.tables.map((t) => t.name),
|
|
12953
|
+
"_00_user_feature",
|
|
12954
|
+
"_00_app_release"
|
|
12955
|
+
];
|
|
12956
|
+
let pendingIds = /* @__PURE__ */ new Set();
|
|
12957
|
+
try {
|
|
12958
|
+
const pending = await this.dataModule.getPendingRecordIds();
|
|
12959
|
+
pendingIds = new Set([...pending.writes, ...pending.deletes]);
|
|
12960
|
+
} catch {}
|
|
12961
|
+
await this.streamProcessor.primeFromLocal({
|
|
12962
|
+
tables,
|
|
12963
|
+
schemaHash: String(hash53(this.config.schemaSurql)),
|
|
12964
|
+
pendingIds,
|
|
12965
|
+
onVersions: (_table, entries) => this.cache.primeVersions(entries)
|
|
12966
|
+
});
|
|
12967
|
+
}
|
|
12422
12968
|
sessionAuthId() {
|
|
12423
12969
|
const id = this.auth.currentUser?.id;
|
|
12424
12970
|
if (!id) return null;
|