@spooky-sync/core 0.0.1-canary.202 → 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 +89 -6
- package/dist/index.js +353 -65
- 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/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/index.ts +78 -14
- package/src/modules/cache/index.ts +12 -11
- 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 +36 -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/services/stream-processor/index.ts +66 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +60 -0
- package/src/services/stream-processor/wasm-types.ts +4 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +156 -50
- package/src/types.ts +6 -0
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
|
/**
|
|
@@ -5873,6 +5984,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
5873
5984
|
if (this.isInit) throw new Error("Sp00kySync is already initialized");
|
|
5874
5985
|
this.isInit = true;
|
|
5875
5986
|
await this.scheduler.init({ loadOutbox: this.tabRole !== "follower" });
|
|
5987
|
+
if (this.remote.getStatus() !== "connected") this.needsResubscribe = true;
|
|
5876
5988
|
this.subscribeToReconnect();
|
|
5877
5989
|
this.subscribeToConnectionState();
|
|
5878
5990
|
this.scheduler.syncUp();
|
|
@@ -6543,6 +6655,12 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6543
6655
|
event,
|
|
6544
6656
|
Category: "sp00ky-client::Sp00kySync::processDownEvent"
|
|
6545
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) {
|
|
6546
6664
|
switch (event.type) {
|
|
6547
6665
|
case "register": return this.registerQuery(event.payload.hash);
|
|
6548
6666
|
case "sync": return this.syncQuery(event.payload.hash);
|
|
@@ -7093,8 +7211,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7093
7211
|
|
|
7094
7212
|
//#endregion
|
|
7095
7213
|
//#region src/modules/devtools/index.ts
|
|
7096
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7097
|
-
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";
|
|
7098
7216
|
const SURREAL_VERSION = "3.0.3";
|
|
7099
7217
|
var DevToolsService = class DevToolsService {
|
|
7100
7218
|
eventsHistory = [];
|
|
@@ -7579,26 +7697,44 @@ var DevToolsService = class DevToolsService {
|
|
|
7579
7697
|
//#endregion
|
|
7580
7698
|
//#region src/modules/auth/index.ts
|
|
7581
7699
|
/**
|
|
7582
|
-
* Read the
|
|
7583
|
-
*
|
|
7584
|
-
*
|
|
7585
|
-
*
|
|
7586
|
-
*
|
|
7700
|
+
* Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
|
|
7701
|
+
* server still enforces the token on every request; this is only so the client
|
|
7702
|
+
* can act on what it already holds before a round trip completes.
|
|
7703
|
+
*
|
|
7704
|
+
* `AC` is the access-method name — the in-browser SSP needs it to resolve
|
|
7705
|
+
* `$access` in table permission predicates (mirrors the session's `$access`
|
|
7706
|
+
* that the server's `fn::query::register` reads). `ID` is the `$auth.id` record
|
|
7707
|
+
* id, which is what lets a warm boot restore a session locally.
|
|
7708
|
+
*
|
|
7709
|
+
* Returns nulls on any malformed input.
|
|
7587
7710
|
*/
|
|
7588
|
-
function
|
|
7711
|
+
function decodeTokenClaims(token) {
|
|
7589
7712
|
try {
|
|
7590
7713
|
const payload = token.split(".")[1];
|
|
7591
|
-
if (!payload) return
|
|
7714
|
+
if (!payload) return {
|
|
7715
|
+
access: null,
|
|
7716
|
+
userId: null
|
|
7717
|
+
};
|
|
7592
7718
|
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
7593
7719
|
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
7594
7720
|
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
7595
7721
|
const claims = JSON.parse(json);
|
|
7596
7722
|
const ac = claims.AC ?? claims.ac;
|
|
7597
|
-
|
|
7723
|
+
const id = claims.ID ?? claims.id;
|
|
7724
|
+
return {
|
|
7725
|
+
access: typeof ac === "string" ? ac : null,
|
|
7726
|
+
userId: typeof id === "string" ? id : null
|
|
7727
|
+
};
|
|
7598
7728
|
} catch {
|
|
7599
|
-
return
|
|
7729
|
+
return {
|
|
7730
|
+
access: null,
|
|
7731
|
+
userId: null
|
|
7732
|
+
};
|
|
7600
7733
|
}
|
|
7601
7734
|
}
|
|
7735
|
+
function decodeAccessFromToken(token) {
|
|
7736
|
+
return decodeTokenClaims(token).access;
|
|
7737
|
+
}
|
|
7602
7738
|
var AuthService = class {
|
|
7603
7739
|
token = null;
|
|
7604
7740
|
currentUser = null;
|
|
@@ -7645,6 +7781,39 @@ var AuthService = class {
|
|
|
7645
7781
|
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
7646
7782
|
}
|
|
7647
7783
|
/**
|
|
7784
|
+
* Restore a session from the locally cached JWT, with NO network.
|
|
7785
|
+
*
|
|
7786
|
+
* This is what makes a warm boot paint instantly and what makes an offline
|
|
7787
|
+
* boot possible at all: the token is in local storage, and it already carries
|
|
7788
|
+
* both the access method and the `$auth.id` record id. Everything the client
|
|
7789
|
+
* needs to route queries (`setCurrentUserId`) and to satisfy `$auth`-gated
|
|
7790
|
+
* permission predicates in the in-browser SSP (`setSessionAuth`) is therefore
|
|
7791
|
+
* available before a socket exists.
|
|
7792
|
+
*
|
|
7793
|
+
* The session is OPTIMISTIC: the token is unverified here. `check()` runs
|
|
7794
|
+
* afterwards in the background and downgrades to a real sign-out if the
|
|
7795
|
+
* server rejects it. Nothing is trusted that the server has not also seen -
|
|
7796
|
+
* the local store only ever holds rows the server previously sent.
|
|
7797
|
+
*
|
|
7798
|
+
* Returns the restored user id, or null when there is no usable token.
|
|
7799
|
+
*/
|
|
7800
|
+
async restoreSessionFromToken() {
|
|
7801
|
+
const token = await this.persistenceClient.get("sp00ky_auth_token");
|
|
7802
|
+
if (!token) return null;
|
|
7803
|
+
const { access, userId } = decodeTokenClaims(token);
|
|
7804
|
+
if (!userId) return null;
|
|
7805
|
+
this.token = token;
|
|
7806
|
+
this.currentUser = { id: userId };
|
|
7807
|
+
this.isAuthenticated = true;
|
|
7808
|
+
this.access = access ?? this.defaultAccessName();
|
|
7809
|
+
this.notifyListeners();
|
|
7810
|
+
this.logger.debug({
|
|
7811
|
+
userId,
|
|
7812
|
+
Category: "sp00ky-client::AuthService::restoreSessionFromToken"
|
|
7813
|
+
}, "Session restored optimistically from cached token");
|
|
7814
|
+
return userId;
|
|
7815
|
+
}
|
|
7816
|
+
/**
|
|
7648
7817
|
* Check for existing session and validate
|
|
7649
7818
|
*/
|
|
7650
7819
|
async check(accessToken) {
|
|
@@ -7685,12 +7854,18 @@ var AuthService = class {
|
|
|
7685
7854
|
}
|
|
7686
7855
|
}
|
|
7687
7856
|
} catch (error) {
|
|
7688
|
-
this.logger.
|
|
7857
|
+
if (classifySyncError(error) === "network") this.logger.warn({
|
|
7689
7858
|
error,
|
|
7690
|
-
stack: error.stack,
|
|
7691
7859
|
Category: "sp00ky-client::AuthService::check"
|
|
7692
|
-
}, "Auth check
|
|
7693
|
-
|
|
7860
|
+
}, "Auth check unreachable; keeping the cached session and retrying later");
|
|
7861
|
+
else {
|
|
7862
|
+
this.logger.error({
|
|
7863
|
+
error,
|
|
7864
|
+
stack: error.stack,
|
|
7865
|
+
Category: "sp00ky-client::AuthService::check"
|
|
7866
|
+
}, "Auth check failed");
|
|
7867
|
+
await this.signOut();
|
|
7868
|
+
}
|
|
7694
7869
|
} finally {
|
|
7695
7870
|
this.isLoading = false;
|
|
7696
7871
|
}
|
|
@@ -7836,6 +8011,51 @@ var StreamProcessorService = class {
|
|
|
7836
8011
|
*/
|
|
7837
8012
|
ingestMany(records) {
|
|
7838
8013
|
if (records.length === 0) return;
|
|
8014
|
+
if (!this.processor) {
|
|
8015
|
+
this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingestMany" }, "Not initialized, skipping ingest");
|
|
8016
|
+
return;
|
|
8017
|
+
}
|
|
8018
|
+
const bulkIngest = this.processor.ingest_many;
|
|
8019
|
+
if (typeof bulkIngest === "function") {
|
|
8020
|
+
this.logger.debug({
|
|
8021
|
+
count: records.length,
|
|
8022
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
8023
|
+
}, "Ingesting batch into ssp");
|
|
8024
|
+
try {
|
|
8025
|
+
const items = records.map((record) => ({
|
|
8026
|
+
table: record.table,
|
|
8027
|
+
op: record.op,
|
|
8028
|
+
id: record.id,
|
|
8029
|
+
record: this.normalizeValue(record.record)
|
|
8030
|
+
}));
|
|
8031
|
+
const t0 = performance.now();
|
|
8032
|
+
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
8033
|
+
const materializationTimeMs = performance.now() - t0;
|
|
8034
|
+
this.logger.debug({
|
|
8035
|
+
count: records.length,
|
|
8036
|
+
rawUpdates: rawUpdates.length,
|
|
8037
|
+
materializationTimeMs,
|
|
8038
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
8039
|
+
}, "Ingesting batch into ssp done");
|
|
8040
|
+
if (rawUpdates.length > 0) this.dispatchUpdates(rawUpdates.map((u) => ({
|
|
8041
|
+
queryHash: u.query_id,
|
|
8042
|
+
localArray: u.result_data,
|
|
8043
|
+
op: "CREATE",
|
|
8044
|
+
materializationTimeMs,
|
|
8045
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
8046
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
8047
|
+
transformMs: u.timing_transform_ms
|
|
8048
|
+
})));
|
|
8049
|
+
} catch (e) {
|
|
8050
|
+
this.logger.error({
|
|
8051
|
+
error: e,
|
|
8052
|
+
count: records.length,
|
|
8053
|
+
Category: "sp00ky-client::StreamProcessorService::ingestMany"
|
|
8054
|
+
}, "Ingesting batch into ssp failed");
|
|
8055
|
+
}
|
|
8056
|
+
this.markSnapshotDirty();
|
|
8057
|
+
return;
|
|
8058
|
+
}
|
|
7839
8059
|
this.beginCoalescing();
|
|
7840
8060
|
try {
|
|
7841
8061
|
for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
|
|
@@ -8363,14 +8583,12 @@ var CacheModule = class {
|
|
|
8363
8583
|
const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
|
|
8364
8584
|
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
8365
8585
|
})));
|
|
8366
|
-
const params =
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
};
|
|
8373
|
-
}, {});
|
|
8586
|
+
const params = {};
|
|
8587
|
+
for (let i = 0; i < populatedRecords.length; i++) {
|
|
8588
|
+
const { id, ...content } = populatedRecords[i].record;
|
|
8589
|
+
params[`id${i}`] = id;
|
|
8590
|
+
params[`content${i}`] = content;
|
|
8591
|
+
}
|
|
8374
8592
|
await this.local.execute(query, params, { epoch });
|
|
8375
8593
|
}
|
|
8376
8594
|
if (this.local.epoch !== epoch) throw new StaleEpochError();
|
|
@@ -11506,6 +11724,15 @@ var Sp00kyClient = class {
|
|
|
11506
11724
|
sync;
|
|
11507
11725
|
devTools;
|
|
11508
11726
|
crdtManager;
|
|
11727
|
+
/**
|
|
11728
|
+
* True once the LOCAL half of boot is done and the client can serve reads
|
|
11729
|
+
* from the local store. Distinct from being connected: `syncHealth` covers
|
|
11730
|
+
* reaching the server and `storageHealth` covers whether the local store is
|
|
11731
|
+
* durable, but neither says "usable". Consumers gate their first paint on
|
|
11732
|
+
* this, which is what makes a warm boot instant and an offline boot possible.
|
|
11733
|
+
*/
|
|
11734
|
+
localReady = false;
|
|
11735
|
+
saltUserId = null;
|
|
11509
11736
|
featureFlags;
|
|
11510
11737
|
appReleases;
|
|
11511
11738
|
preloadedHashes = /* @__PURE__ */ new Set();
|
|
@@ -11608,6 +11835,7 @@ var Sp00kyClient = class {
|
|
|
11608
11835
|
anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
|
|
11609
11836
|
degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3,
|
|
11610
11837
|
pushTimeoutMs: this.config.pushTimeoutMs,
|
|
11838
|
+
downTimeoutMs: this.config.downTimeoutMs,
|
|
11611
11839
|
connectionSupervisor: this.connectionSupervisor
|
|
11612
11840
|
});
|
|
11613
11841
|
this.featureFlags = new FeatureFlagModule({
|
|
@@ -11662,7 +11890,7 @@ var Sp00kyClient = class {
|
|
|
11662
11890
|
return new TabsCoordinator({
|
|
11663
11891
|
tabId,
|
|
11664
11892
|
fingerprint: computeTabsFingerprint({
|
|
11665
|
-
coreVersion: "0.0.1-canary.
|
|
11893
|
+
coreVersion: "0.0.1-canary.204",
|
|
11666
11894
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11667
11895
|
endpoint: this.config.database.endpoint ?? "",
|
|
11668
11896
|
namespace: this.config.database.namespace,
|
|
@@ -11805,9 +12033,6 @@ var Sp00kyClient = class {
|
|
|
11805
12033
|
await this.migrator.provision(this.config.schemaSurql);
|
|
11806
12034
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
|
|
11807
12035
|
}
|
|
11808
|
-
await this.remote.connect();
|
|
11809
|
-
this.connectionSupervisor.start();
|
|
11810
|
-
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
|
|
11811
12036
|
(async () => {
|
|
11812
12037
|
try {
|
|
11813
12038
|
this.blobs.setMaxBytes(await resolveBlobBudget(this.config.blobCache?.maxBytes));
|
|
@@ -11823,23 +12048,31 @@ var Sp00kyClient = class {
|
|
|
11823
12048
|
await this.streamProcessor.init();
|
|
11824
12049
|
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
11825
12050
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
|
|
11826
|
-
await this.auth.
|
|
11827
|
-
this.
|
|
11828
|
-
|
|
12051
|
+
const restoredUserId = await this.auth.restoreSessionFromToken();
|
|
12052
|
+
const sessionId = this.mintSessionSalt();
|
|
12053
|
+
this.saltUserId = restoredUserId;
|
|
11829
12054
|
await this.dataModule.init(sessionId);
|
|
11830
12055
|
this.crdtManager.setSessionId(sessionId);
|
|
12056
|
+
if (restoredUserId) {
|
|
12057
|
+
this.dataModule.setCurrentUserId(restoredUserId);
|
|
12058
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
12059
|
+
}
|
|
11831
12060
|
this.logger.debug({
|
|
11832
12061
|
sessionId,
|
|
11833
12062
|
Category: "sp00ky-client::Sp00kyClient::init"
|
|
11834
12063
|
}, "DataModule initialized");
|
|
11835
12064
|
this.auth.subscribe(async (userId) => {
|
|
11836
12065
|
this.dataModule.setCurrentUserId(userId);
|
|
11837
|
-
this.streamProcessor.setSessionAuth(this.
|
|
12066
|
+
this.streamProcessor.setSessionAuth(this.sessionAuthId(), this.auth.access);
|
|
11838
12067
|
writeBootBucketHint(bucketIdForUser(userId));
|
|
11839
12068
|
await this.ensureLocalBucket(userId);
|
|
11840
|
-
const
|
|
11841
|
-
this.
|
|
11842
|
-
|
|
12069
|
+
const saltFor = this.sessionAuthId();
|
|
12070
|
+
if (saltFor !== this.saltUserId) {
|
|
12071
|
+
this.saltUserId = saltFor;
|
|
12072
|
+
const next = this.mintSessionSalt();
|
|
12073
|
+
this.dataModule.setSessionId(next);
|
|
12074
|
+
this.crdtManager.setSessionId(next);
|
|
12075
|
+
}
|
|
11843
12076
|
try {
|
|
11844
12077
|
await this.sync.setCurrentUserId(userId);
|
|
11845
12078
|
} catch (e) {
|
|
@@ -11855,7 +12088,9 @@ var Sp00kyClient = class {
|
|
|
11855
12088
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
|
|
11856
12089
|
this.appReleases.init();
|
|
11857
12090
|
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "AppReleaseModule initialized");
|
|
11858
|
-
this.
|
|
12091
|
+
this.localReady = true;
|
|
12092
|
+
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient local initialization completed; connecting in the background");
|
|
12093
|
+
this.initRemote();
|
|
11859
12094
|
} catch (e) {
|
|
11860
12095
|
this.logger.error({
|
|
11861
12096
|
error: e,
|
|
@@ -11864,6 +12099,36 @@ var Sp00kyClient = class {
|
|
|
11864
12099
|
throw e;
|
|
11865
12100
|
}
|
|
11866
12101
|
}
|
|
12102
|
+
/**
|
|
12103
|
+
* The network half of boot: connect, verify the restored session, and let the
|
|
12104
|
+
* sync engine catch up. Runs in the background after `init()` has already
|
|
12105
|
+
* resolved, so nothing here is on the paint path.
|
|
12106
|
+
*
|
|
12107
|
+
* Every step is best-effort. A failure leaves the client in exactly the state
|
|
12108
|
+
* a warm offline boot is in - local reads working, writes queued in the
|
|
12109
|
+
* outbox - and the connection supervisor keeps retrying underneath.
|
|
12110
|
+
*/
|
|
12111
|
+
async initRemote() {
|
|
12112
|
+
try {
|
|
12113
|
+
await this.remote.connect();
|
|
12114
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Remote database connected");
|
|
12115
|
+
} catch (e) {
|
|
12116
|
+
this.logger.warn({
|
|
12117
|
+
err: e,
|
|
12118
|
+
Category: "sp00ky-client::Sp00kyClient::initRemote"
|
|
12119
|
+
}, "Remote connect failed; running from the local store and retrying in the background");
|
|
12120
|
+
}
|
|
12121
|
+
this.connectionSupervisor.start();
|
|
12122
|
+
try {
|
|
12123
|
+
await this.auth.init();
|
|
12124
|
+
this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::initRemote" }, "Auth verified");
|
|
12125
|
+
} catch (e) {
|
|
12126
|
+
this.logger.warn({
|
|
12127
|
+
err: e,
|
|
12128
|
+
Category: "sp00ky-client::Sp00kyClient::initRemote"
|
|
12129
|
+
}, "Auth verification failed; keeping the restored session");
|
|
12130
|
+
}
|
|
12131
|
+
}
|
|
11867
12132
|
bucketSwitchChain = Promise.resolve();
|
|
11868
12133
|
pendingBucketTarget = null;
|
|
11869
12134
|
/**
|
|
@@ -12238,26 +12503,49 @@ var Sp00kyClient = class {
|
|
|
12238
12503
|
delete(table, id) {
|
|
12239
12504
|
return this.dataModule.delete(table, id);
|
|
12240
12505
|
}
|
|
12506
|
+
/**
|
|
12507
|
+
* Whether the local store is initialized and reads can be served. See the
|
|
12508
|
+
* `localReady` field: this is deliberately independent of connectivity.
|
|
12509
|
+
*/
|
|
12510
|
+
isLocalReady() {
|
|
12511
|
+
return this.localReady;
|
|
12512
|
+
}
|
|
12241
12513
|
async useRemote(fn) {
|
|
12242
12514
|
return fn(this.remote.getClient());
|
|
12243
12515
|
}
|
|
12244
12516
|
/**
|
|
12245
|
-
*
|
|
12246
|
-
*
|
|
12247
|
-
*
|
|
12248
|
-
*
|
|
12249
|
-
|
|
12250
|
-
|
|
12251
|
-
|
|
12252
|
-
|
|
12253
|
-
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
|
|
12260
|
-
|
|
12517
|
+
* Mint the salt used for query-id hashing, so two sessions registering the
|
|
12518
|
+
* same logical query get distinct `_00_query` rows.
|
|
12519
|
+
*
|
|
12520
|
+
* Generated LOCALLY, deliberately. This used to be `RETURN <string>session::id()`,
|
|
12521
|
+
* which cost a serial round trip on the critical boot path and resolved to
|
|
12522
|
+
* `''` offline. The value never needed to come from the server: the server
|
|
12523
|
+
* derives its own `clientId` inside `fn::query::register` and *ignores*
|
|
12524
|
+
* whatever the caller passed, and the permission rules that matter gate on
|
|
12525
|
+
* `auth_id = $auth.id` rather than the session (`_00_list_ref`). Session
|
|
12526
|
+
* scoping via `clientId = session::id()` was in fact removed upstream because
|
|
12527
|
+
* it broke a user with two tabs open. All this value has to be is unique per
|
|
12528
|
+
* browser session, which `randomUUID` gives us for free and offline.
|
|
12529
|
+
*/
|
|
12530
|
+
/**
|
|
12531
|
+
* The current principal as the `"table:id"` string the in-browser SSP wants
|
|
12532
|
+
* for `$auth.id`, or null when signed out.
|
|
12533
|
+
*
|
|
12534
|
+
* Tolerates BOTH shapes `currentUser.id` can take, which is the point:
|
|
12535
|
+
* a session restored from the cached token carries a plain string (the JWT's
|
|
12536
|
+
* `ID` claim), while one verified by the server carries a RecordId. Passing
|
|
12537
|
+
* the former to `encodeRecordId` reads `.table` off a string and throws
|
|
12538
|
+
* during boot.
|
|
12539
|
+
*/
|
|
12540
|
+
sessionAuthId() {
|
|
12541
|
+
const id = this.auth.currentUser?.id;
|
|
12542
|
+
if (!id) return null;
|
|
12543
|
+
return typeof id === "string" ? id : encodeRecordId(id);
|
|
12544
|
+
}
|
|
12545
|
+
mintSessionSalt() {
|
|
12546
|
+
const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
|
|
12547
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
12548
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
12261
12549
|
}
|
|
12262
12550
|
};
|
|
12263
12551
|
|