@spooky-sync/core 0.0.1-canary.325 → 0.0.1-canary.327
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/AGENTS.md +2 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +456 -353
- package/package.json +3 -3
- package/src/client/runtime.ts +4 -4
- package/src/client/sp00ky-client.ts +1 -1
- package/src/modules/crdt/crdt.test.ts +49 -1
- package/src/modules/crdt/index.ts +35 -13
- package/src/query/id-set-plan.test.ts +2 -2
- package/src/query/lifecycle.saga.ts +28 -1
- package/src/query/materialize.test.ts +1 -1
- package/src/query/membership.saga.test.ts +34 -0
- package/src/query/membership.saga.ts +4 -0
- package/src/query/window-query.test.ts +6 -6
- package/src/query/window-query.ts +14 -1
- package/src/services/database/sqlite-cache-engine.test.ts +11 -0
- package/src/services/database/surql-translate.ts +4 -3
- package/src/services/database/surreal-cache-engine.ts +3 -1
- package/src/state/selectors.test.ts +13 -0
- package/src/state/selectors.ts +37 -1
package/dist/index.js
CHANGED
|
@@ -2188,8 +2188,8 @@ function trimHistory(history, cap) {
|
|
|
2188
2188
|
|
|
2189
2189
|
//#endregion
|
|
2190
2190
|
//#region src/modules/devtools/index.ts
|
|
2191
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2191
|
+
const CORE_VERSION = "0.0.1-canary.327";
|
|
2192
|
+
const WASM_VERSION = "0.0.1-canary.327";
|
|
2193
2193
|
const SURREAL_VERSION = "3.0.3";
|
|
2194
2194
|
var DevToolsService = class DevToolsService {
|
|
2195
2195
|
version = CORE_VERSION;
|
|
@@ -3548,6 +3548,22 @@ const compose = (...reducers) => (s) => reducers.reduce((acc, r) => r(acc), s);
|
|
|
3548
3548
|
//#endregion
|
|
3549
3549
|
//#region src/state/selectors.ts
|
|
3550
3550
|
const activeHashes = (s) => [...s.queries.keys()];
|
|
3551
|
+
/**
|
|
3552
|
+
* The status subscribers see. `fetching` while a fetch cycle is in flight,
|
|
3553
|
+
* and also while a live query is not yet `settled`: the server's membership
|
|
3554
|
+
* is in but its rows are still downloading or not yet shown. Registration
|
|
3555
|
+
* ends its cycle as soon as membership is applied and the body fetch starts
|
|
3556
|
+
* its own a moment later, so the fetch count alone read `idle` in between,
|
|
3557
|
+
* and a binding that settles on "membership known and idle" told the app a
|
|
3558
|
+
* row did not exist while it was on its way (a by-id screen showed "not
|
|
3559
|
+
* found" for a moment, or for as long as the bodies took).
|
|
3560
|
+
*/
|
|
3561
|
+
const queryStatus = (s, hash) => {
|
|
3562
|
+
const e = s.queries.get(hash);
|
|
3563
|
+
if (!e) return void 0;
|
|
3564
|
+
if (deriveStatus(e.lifecycle) === "fetching") return "fetching";
|
|
3565
|
+
return e.lifecycle.phase === "live" && !settled(s, hash) ? "fetching" : "idle";
|
|
3566
|
+
};
|
|
3551
3567
|
/** The optimistic overlay, derived from the outbox (pending AND acked items). */
|
|
3552
3568
|
function overlay(s) {
|
|
3553
3569
|
const writes = /* @__PURE__ */ new Set();
|
|
@@ -3588,6 +3604,28 @@ function retained(s) {
|
|
|
3588
3604
|
const known = vouched(s);
|
|
3589
3605
|
return (id) => id.startsWith("_00_") || known.has(id) || held.has(id);
|
|
3590
3606
|
}
|
|
3607
|
+
/**
|
|
3608
|
+
* Ids something live still needs: a mounted query's membership or subquery
|
|
3609
|
+
* children, or a local write not yet settled. Unlike `retained`, durable view
|
|
3610
|
+
* rows of queries nobody runs right now do not count: a row the server just
|
|
3611
|
+
* took out of a live query must stop showing even if an old view named it.
|
|
3612
|
+
*/
|
|
3613
|
+
function activelyHeld(s) {
|
|
3614
|
+
const held = /* @__PURE__ */ new Set();
|
|
3615
|
+
for (const e of s.queries.values()) {
|
|
3616
|
+
for (const [id] of e.remoteArray) held.add(id);
|
|
3617
|
+
for (const [id] of e.subqueryRemoteArray) held.add(id);
|
|
3618
|
+
}
|
|
3619
|
+
for (const item of s.outbox) held.add(item.recordId);
|
|
3620
|
+
for (const write of s.pendingWrites.values()) held.add(write.recordId);
|
|
3621
|
+
return held;
|
|
3622
|
+
}
|
|
3623
|
+
/** Ids of `before` that `after` no longer names. */
|
|
3624
|
+
function departedIds(before, after) {
|
|
3625
|
+
if (before.length === 0) return [];
|
|
3626
|
+
const kept = new Set(after.map(([id]) => id));
|
|
3627
|
+
return before.filter(([id]) => !kept.has(id)).map(([id]) => id);
|
|
3628
|
+
}
|
|
3591
3629
|
const pendingDeleteIds = (s) => new Set(s.outbox.filter((i) => i.type === "delete").map((i) => i.recordId));
|
|
3592
3630
|
const hasAckedWrites = (s) => s.outbox.some((i) => i.status === "acked");
|
|
3593
3631
|
const pendingMutationCount = (s) => s.outbox.filter((i) => i.status === "pending").length;
|
|
@@ -4197,6 +4235,343 @@ function suspectHashes(snapshots, heldCounts, edgeCount) {
|
|
|
4197
4235
|
return out;
|
|
4198
4236
|
}
|
|
4199
4237
|
|
|
4238
|
+
//#endregion
|
|
4239
|
+
//#region src/query/sql.ts
|
|
4240
|
+
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
4241
|
+
const VIEW_TABLE = "_00_view";
|
|
4242
|
+
const LEGACY_VIEW_TABLE = "_00_window";
|
|
4243
|
+
const viewRecordId = (viewKey) => new RecordId$1(VIEW_TABLE, viewKey);
|
|
4244
|
+
/**
|
|
4245
|
+
* The durable `_00_view` row: the confirmed membership plus the subquery
|
|
4246
|
+
* children, which the GC must keep and a cold scan may paint as much as the
|
|
4247
|
+
* members.
|
|
4248
|
+
*/
|
|
4249
|
+
const viewRow = (ids, children, confirmed, now) => ({
|
|
4250
|
+
ids,
|
|
4251
|
+
children,
|
|
4252
|
+
confirmed,
|
|
4253
|
+
updatedAt: now
|
|
4254
|
+
});
|
|
4255
|
+
/** Every `_00_view` row: the durable view index and the GC read it. */
|
|
4256
|
+
const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
|
|
4257
|
+
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
4258
|
+
const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
4259
|
+
const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
4260
|
+
const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
4261
|
+
const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
4262
|
+
const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
|
|
4263
|
+
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
|
|
4264
|
+
const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
|
|
4265
|
+
/** The single-query read: edges, meta, subquery children in one request. */
|
|
4266
|
+
const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
4267
|
+
/** The many-query read: all edges + all metas in one request. */
|
|
4268
|
+
const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
4269
|
+
/**
|
|
4270
|
+
* The session's one LIVE subscription on its `_00_list_ref` table.
|
|
4271
|
+
*
|
|
4272
|
+
* `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
|
|
4273
|
+
* notification so the body arrives with the doorbell.
|
|
4274
|
+
*
|
|
4275
|
+
* On the anon table it is filtered to this client's own edges. Every
|
|
4276
|
+
* signed-out session shares that table, so unfiltered each one received (and
|
|
4277
|
+
* the server fetched a body for) every anonymous edge change of every other
|
|
4278
|
+
* session, and the client then dropped all but its own. An edge carries the
|
|
4279
|
+
* `session::id()` of whichever socket last registered its view, which is a
|
|
4280
|
+
* socket of this client but not necessarily the current one: a view stays
|
|
4281
|
+
* registered across a reconnect, and a reload keeps its salt and its views,
|
|
4282
|
+
* so their older edges still name an earlier socket. `sessionIds` are those
|
|
4283
|
+
* earlier sockets. The current one is matched by `session::id()` itself,
|
|
4284
|
+
* which SurrealDB evaluates in the subscriber's own session, so the filter
|
|
4285
|
+
* holds before the client has learned its new id.
|
|
4286
|
+
*
|
|
4287
|
+
* The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
|
|
4288
|
+
* and without it nothing matches. Per-user tables need no filter.
|
|
4289
|
+
*/
|
|
4290
|
+
function listRefLive(table, opts) {
|
|
4291
|
+
const join = opts.inlineBodies ? " FETCH out" : "";
|
|
4292
|
+
if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
|
|
4293
|
+
return {
|
|
4294
|
+
sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
|
|
4295
|
+
vars: { sids: [...opts.sessionIds] }
|
|
4296
|
+
};
|
|
4297
|
+
}
|
|
4298
|
+
/** Register + read back edges/meta/children in ONE request. */
|
|
4299
|
+
const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
4300
|
+
const registerVars = (payload) => ({
|
|
4301
|
+
config: payload,
|
|
4302
|
+
in: payload.id
|
|
4303
|
+
});
|
|
4304
|
+
/** One heartbeat statement per view id; result index i answers id i. */
|
|
4305
|
+
function heartbeatBatch(ids) {
|
|
4306
|
+
const vars = {};
|
|
4307
|
+
return {
|
|
4308
|
+
sql: ids.map((id, i) => {
|
|
4309
|
+
vars[`id${i}`] = id;
|
|
4310
|
+
return `fn::query::heartbeat($id${i})`;
|
|
4311
|
+
}).join(";\n"),
|
|
4312
|
+
vars
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
4316
|
+
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
4317
|
+
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
4318
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4319
|
+
/**
|
|
4320
|
+
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
4321
|
+
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
4322
|
+
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
4323
|
+
* interpolated. `null` when there is nothing the server could act on: with no
|
|
4324
|
+
* session ids it could not tell this page's entries from anyone else's.
|
|
4325
|
+
*/
|
|
4326
|
+
function leaveViewsBeacon(ids, sessionIds) {
|
|
4327
|
+
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
4328
|
+
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
4329
|
+
if (list.length === 0 || sids.length === 0) return null;
|
|
4330
|
+
const sidList = sids.map((sid) => `'${sid}'`).join(", ");
|
|
4331
|
+
return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
4332
|
+
}
|
|
4333
|
+
const bodySelect = () => "SELECT * FROM $ids";
|
|
4334
|
+
/** Local transaction that MERGEs many fetched bodies. */
|
|
4335
|
+
function upsertBodiesTx(records) {
|
|
4336
|
+
const vars = {};
|
|
4337
|
+
const stmts = records.map((r, i) => {
|
|
4338
|
+
vars[`id${i}`] = r.id;
|
|
4339
|
+
vars[`content${i}`] = r.content;
|
|
4340
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
4341
|
+
});
|
|
4342
|
+
return {
|
|
4343
|
+
query: surql.seal(surql.tx(stmts)),
|
|
4344
|
+
vars
|
|
4345
|
+
};
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
//#endregion
|
|
4349
|
+
//#region src/query/lifecycle.saga.ts
|
|
4350
|
+
/**
|
|
4351
|
+
* One tick for every query in state: evict the ones nobody has watched for a
|
|
4352
|
+
* ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
|
|
4353
|
+
* itself at half the shortest ttl.
|
|
4354
|
+
*/
|
|
4355
|
+
function* lifecycleTick(env) {
|
|
4356
|
+
const now = yield fx.now();
|
|
4357
|
+
const state = yield fx.state.read((s) => s);
|
|
4358
|
+
for (const hash of evictable(state, now)) yield* evictQuery(hash);
|
|
4359
|
+
const remaining = yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered"));
|
|
4360
|
+
if (remaining.length > 0) {
|
|
4361
|
+
const beat = heartbeatBatch(remaining.map((e) => e.def.id));
|
|
4362
|
+
try {
|
|
4363
|
+
const results = yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs);
|
|
4364
|
+
let reclaimed = 0;
|
|
4365
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
4366
|
+
const r = results[i];
|
|
4367
|
+
if (r?.status === "OK" && heartbeatRowGone(r.result)) {
|
|
4368
|
+
reclaimed++;
|
|
4369
|
+
yield fx.state.update(applyLifecycle(remaining[i].def.hash, { type: "remote-dropped" }));
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
yield fx.state.update(stampHeartbeat(remaining.map((e) => e.def.hash), now));
|
|
4373
|
+
if (reclaimed > 0) {
|
|
4374
|
+
yield fx.emit({
|
|
4375
|
+
type: "log",
|
|
4376
|
+
level: "warn",
|
|
4377
|
+
message: "query rows reclaimed by the server; re-registering",
|
|
4378
|
+
data: { reclaimed }
|
|
4379
|
+
});
|
|
4380
|
+
yield fx.dispatch({ type: "EnsureRegistered" });
|
|
4381
|
+
}
|
|
4382
|
+
yield fx.dispatch({
|
|
4383
|
+
type: "SyncOutcome",
|
|
4384
|
+
ok: true
|
|
4385
|
+
});
|
|
4386
|
+
} catch (error) {
|
|
4387
|
+
yield fx.dispatch({
|
|
4388
|
+
type: "SyncOutcome",
|
|
4389
|
+
ok: false,
|
|
4390
|
+
error
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
const ttl = yield fx.state.read((s) => shortestTtlMs(s));
|
|
4395
|
+
yield fx.timer.set("lifecycle", Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: "LifecycleTick" });
|
|
4396
|
+
}
|
|
4397
|
+
/** Free a query's local view and forget it. The server row expires by TTL. */
|
|
4398
|
+
function* evictQuery(hash) {
|
|
4399
|
+
if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
|
|
4400
|
+
try {
|
|
4401
|
+
yield fx.ssp.unregister(hash);
|
|
4402
|
+
} catch (error) {
|
|
4403
|
+
yield fx.emit({
|
|
4404
|
+
type: "log",
|
|
4405
|
+
level: "debug",
|
|
4406
|
+
message: "unregister failed",
|
|
4407
|
+
data: {
|
|
4408
|
+
hash,
|
|
4409
|
+
error
|
|
4410
|
+
}
|
|
4411
|
+
});
|
|
4412
|
+
}
|
|
4413
|
+
yield fx.state.update(removeQuery(hash));
|
|
4414
|
+
yield fx.emit({
|
|
4415
|
+
type: "query:evicted",
|
|
4416
|
+
hash
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
/** Drop acked outbox items membership never named within the grace window. */
|
|
4420
|
+
function* ackPrune() {
|
|
4421
|
+
const now = yield fx.now();
|
|
4422
|
+
yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
|
|
4423
|
+
if (yield fx.state.read((s) => s.outbox.some((i) => i.status === "acked"))) yield fx.timer.set("ack-prune", ACK_GRACE_MS, { type: "AckPrune" });
|
|
4424
|
+
}
|
|
4425
|
+
/** Every `_00_view` row, parsed. Throws when the read fails. */
|
|
4426
|
+
function* readViews() {
|
|
4427
|
+
const res = yield fx.local.query(readViewRows());
|
|
4428
|
+
return parseViewIndex(Array.isArray(res) ? res[0] : void 0);
|
|
4429
|
+
}
|
|
4430
|
+
const indexOf = (rows) => new Map(rows.map((r) => [r.key, r.ids]));
|
|
4431
|
+
/**
|
|
4432
|
+
* Load the durable view index (boot, bucket switch). A failed read leaves it
|
|
4433
|
+
* empty, which fails closed: cold scans paint nothing until the server answers.
|
|
4434
|
+
*/
|
|
4435
|
+
function* loadViews() {
|
|
4436
|
+
try {
|
|
4437
|
+
yield fx.state.update(reloadViews(indexOf(yield* readViews())));
|
|
4438
|
+
} catch (error) {
|
|
4439
|
+
yield fx.emit({
|
|
4440
|
+
type: "log",
|
|
4441
|
+
level: "warn",
|
|
4442
|
+
message: "view index read failed",
|
|
4443
|
+
data: { error }
|
|
4444
|
+
});
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
const tableOf$1 = (id) => id.slice(0, id.indexOf(":"));
|
|
4448
|
+
/**
|
|
4449
|
+
* Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
|
|
4450
|
+
* `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
|
|
4451
|
+
* rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
|
|
4452
|
+
* nothing retains (see `retained`) from the store and the circuit, a chunk at
|
|
4453
|
+
* a time. Only a leader or solo tab deletes, and a leader relays the deletes
|
|
4454
|
+
* so followers drop the rows too; a follower only refreshes its index.
|
|
4455
|
+
*/
|
|
4456
|
+
function* gcTick() {
|
|
4457
|
+
yield fx.state.wait((s) => s.primed);
|
|
4458
|
+
try {
|
|
4459
|
+
const rows = yield* readViews();
|
|
4460
|
+
const [role, bucket, held] = yield fx.state.read((s) => [
|
|
4461
|
+
s.tabRole,
|
|
4462
|
+
s.bucketId,
|
|
4463
|
+
new Set([...s.queries.values()].map((e) => e.def.viewKey))
|
|
4464
|
+
]);
|
|
4465
|
+
const now = yield fx.now();
|
|
4466
|
+
const expired = role === "follower" ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
|
|
4467
|
+
const retired = /* @__PURE__ */ new Set();
|
|
4468
|
+
for (let i = 0; i < expired.length; i += GC_CHUNK) {
|
|
4469
|
+
const chunk = expired.slice(i, i + GC_CHUNK);
|
|
4470
|
+
const settled = yield fx.all(chunk.map((r) => fx.local.delete(VIEW_TABLE, viewRecordId(r.key))));
|
|
4471
|
+
chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
|
|
4472
|
+
}
|
|
4473
|
+
yield fx.state.update(reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
|
|
4474
|
+
if (role === "follower") return;
|
|
4475
|
+
const removed = yield* collectOrphans(bucket, role === "leader");
|
|
4476
|
+
yield fx.emit({
|
|
4477
|
+
type: "log",
|
|
4478
|
+
level: "info",
|
|
4479
|
+
message: "orphan gc done",
|
|
4480
|
+
data: {
|
|
4481
|
+
removed,
|
|
4482
|
+
retiredViews: retired.size
|
|
4483
|
+
}
|
|
4484
|
+
});
|
|
4485
|
+
} catch (error) {
|
|
4486
|
+
yield fx.emit({
|
|
4487
|
+
type: "log",
|
|
4488
|
+
level: "warn",
|
|
4489
|
+
message: "orphan gc failed",
|
|
4490
|
+
data: { error }
|
|
4491
|
+
});
|
|
4492
|
+
} finally {
|
|
4493
|
+
yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Forget rows the server just took out of `hash` (its membership or its
|
|
4498
|
+
* subquery children) when nothing live still holds them: delete the local
|
|
4499
|
+
* body and the in-browser circuit's copy, and re-render the query.
|
|
4500
|
+
*
|
|
4501
|
+
* Primary rows already stop rendering once they leave membership, but
|
|
4502
|
+
* subquery children are rendered from the local store (a thread's comments
|
|
4503
|
+
* are read back by the relation), so a comment deleted on another device
|
|
4504
|
+
* stayed on screen until the hourly GC or a reload. Only a leader or solo tab
|
|
4505
|
+
* deletes, and a leader relays it to its followers, as the GC does.
|
|
4506
|
+
*/
|
|
4507
|
+
function* forgetDeparted(hash, departed) {
|
|
4508
|
+
if (departed.length === 0) return;
|
|
4509
|
+
const [gone, role] = yield fx.state.read((s) => {
|
|
4510
|
+
const held = activelyHeld(s);
|
|
4511
|
+
return [departed.filter((id) => !id.startsWith("_00_") && s.versions.has(id) && !held.has(id)), s.tabRole];
|
|
4512
|
+
});
|
|
4513
|
+
if (gone.length === 0 || role === "follower") return;
|
|
4514
|
+
const settled = yield fx.all(gone.map((id) => fx.local.delete(tableOf$1(id), id)));
|
|
4515
|
+
const done = gone.filter((_, j) => settled[j].ok);
|
|
4516
|
+
if (done.length === 0) return;
|
|
4517
|
+
const records = done.map((id) => ({
|
|
4518
|
+
table: tableOf$1(id),
|
|
4519
|
+
op: "DELETE",
|
|
4520
|
+
id,
|
|
4521
|
+
record: {}
|
|
4522
|
+
}));
|
|
4523
|
+
yield fx.state.update(compose(deleteVersions(done), markDirty([hash])));
|
|
4524
|
+
yield fx.ssp.ingest(records);
|
|
4525
|
+
if (role === "leader") yield fx.emit({
|
|
4526
|
+
type: "tabs:broadcast",
|
|
4527
|
+
message: {
|
|
4528
|
+
type: "ingest",
|
|
4529
|
+
records
|
|
4530
|
+
}
|
|
4531
|
+
});
|
|
4532
|
+
}
|
|
4533
|
+
/**
|
|
4534
|
+
* Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
|
|
4535
|
+
* re-checked against fresh state (a membership committed meanwhile may name
|
|
4536
|
+
* an id again) and the sweep stops if the bucket moved under it.
|
|
4537
|
+
*/
|
|
4538
|
+
function* collectOrphans(bucket, relay) {
|
|
4539
|
+
const candidates = yield fx.state.read((s) => {
|
|
4540
|
+
const keep = retained(s);
|
|
4541
|
+
return [...s.versions.keys()].filter((id) => !keep(id));
|
|
4542
|
+
});
|
|
4543
|
+
let removed = 0;
|
|
4544
|
+
for (let i = 0; i < candidates.length; i += GC_CHUNK) {
|
|
4545
|
+
const chunk = yield fx.state.read((s) => {
|
|
4546
|
+
if (!s.primed || s.bucketId !== bucket) return null;
|
|
4547
|
+
const keep = retained(s);
|
|
4548
|
+
return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
|
|
4549
|
+
});
|
|
4550
|
+
if (chunk === null) break;
|
|
4551
|
+
const settled = yield fx.all(chunk.map((id) => fx.local.delete(tableOf$1(id), id)));
|
|
4552
|
+
const done = chunk.filter((_, j) => settled[j].ok);
|
|
4553
|
+
if (done.length === 0) continue;
|
|
4554
|
+
const records = done.map((id) => ({
|
|
4555
|
+
table: tableOf$1(id),
|
|
4556
|
+
op: "DELETE",
|
|
4557
|
+
id,
|
|
4558
|
+
record: {}
|
|
4559
|
+
}));
|
|
4560
|
+
yield fx.state.update(deleteVersions(done));
|
|
4561
|
+
yield fx.ssp.ingest(records);
|
|
4562
|
+
if (relay) yield fx.emit({
|
|
4563
|
+
type: "tabs:broadcast",
|
|
4564
|
+
message: {
|
|
4565
|
+
type: "ingest",
|
|
4566
|
+
records
|
|
4567
|
+
}
|
|
4568
|
+
});
|
|
4569
|
+
removed += done.length;
|
|
4570
|
+
}
|
|
4571
|
+
if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
|
|
4572
|
+
return removed;
|
|
4573
|
+
}
|
|
4574
|
+
|
|
4200
4575
|
//#endregion
|
|
4201
4576
|
//#region src/sync/policy.ts
|
|
4202
4577
|
/** Known edges one poll round trip may carry before the cycle is split. */
|
|
@@ -4315,149 +4690,39 @@ function nextHealth(input, ok, error, degradeAfter) {
|
|
|
4315
4690
|
...input.health,
|
|
4316
4691
|
status: "healthy",
|
|
4317
4692
|
kind: void 0,
|
|
4318
|
-
error: void 0,
|
|
4319
|
-
everConnected
|
|
4320
|
-
},
|
|
4321
|
-
recoveredNow: recovered
|
|
4322
|
-
};
|
|
4323
|
-
}
|
|
4324
|
-
const consecutiveFailures = input.consecutiveFailures + 1;
|
|
4325
|
-
const kind = classifySyncError(error);
|
|
4326
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4327
|
-
if (input.health.status !== "degraded" && consecutiveFailures >= degradeAfter) return {
|
|
4328
|
-
...base,
|
|
4329
|
-
consecutiveFailures,
|
|
4330
|
-
health: {
|
|
4331
|
-
...input.health,
|
|
4332
|
-
status: "degraded",
|
|
4333
|
-
consecutiveFailures,
|
|
4334
|
-
kind,
|
|
4335
|
-
error: message
|
|
4336
|
-
},
|
|
4337
|
-
degradedNow: true
|
|
4338
|
-
};
|
|
4339
|
-
return {
|
|
4340
|
-
...base,
|
|
4341
|
-
consecutiveFailures,
|
|
4342
|
-
health: {
|
|
4343
|
-
...input.health,
|
|
4344
|
-
consecutiveFailures,
|
|
4345
|
-
kind,
|
|
4346
|
-
error: message
|
|
4347
|
-
}
|
|
4348
|
-
};
|
|
4349
|
-
}
|
|
4350
|
-
const selfHealDelayMs = (attempt) => Math.min(SELF_HEAL_MAX_MS, SELF_HEAL_BASE_MS * 2 ** Math.min(attempt, 30));
|
|
4351
|
-
|
|
4352
|
-
//#endregion
|
|
4353
|
-
//#region src/query/sql.ts
|
|
4354
|
-
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
4355
|
-
const VIEW_TABLE = "_00_view";
|
|
4356
|
-
const LEGACY_VIEW_TABLE = "_00_window";
|
|
4357
|
-
const viewRecordId = (viewKey) => new RecordId$1(VIEW_TABLE, viewKey);
|
|
4358
|
-
/**
|
|
4359
|
-
* The durable `_00_view` row: the confirmed membership plus the subquery
|
|
4360
|
-
* children, which the GC must keep and a cold scan may paint as much as the
|
|
4361
|
-
* members.
|
|
4362
|
-
*/
|
|
4363
|
-
const viewRow = (ids, children, confirmed, now) => ({
|
|
4364
|
-
ids,
|
|
4365
|
-
children,
|
|
4366
|
-
confirmed,
|
|
4367
|
-
updatedAt: now
|
|
4368
|
-
});
|
|
4369
|
-
/** Every `_00_view` row: the durable view index and the GC read it. */
|
|
4370
|
-
const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
|
|
4371
|
-
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
4372
|
-
const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
4373
|
-
const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
4374
|
-
const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
|
|
4375
|
-
const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
|
|
4376
|
-
const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
|
|
4377
|
-
const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
|
|
4378
|
-
const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
|
|
4379
|
-
/** The single-query read: edges, meta, subquery children in one request. */
|
|
4380
|
-
const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
4381
|
-
/** The many-query read: all edges + all metas in one request. */
|
|
4382
|
-
const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
4383
|
-
/**
|
|
4384
|
-
* The session's one LIVE subscription on its `_00_list_ref` table.
|
|
4385
|
-
*
|
|
4386
|
-
* `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
|
|
4387
|
-
* notification so the body arrives with the doorbell.
|
|
4388
|
-
*
|
|
4389
|
-
* On the anon table it is filtered to this client's own edges. Every
|
|
4390
|
-
* signed-out session shares that table, so unfiltered each one received (and
|
|
4391
|
-
* the server fetched a body for) every anonymous edge change of every other
|
|
4392
|
-
* session, and the client then dropped all but its own. An edge carries the
|
|
4393
|
-
* `session::id()` of whichever socket last registered its view, which is a
|
|
4394
|
-
* socket of this client but not necessarily the current one: a view stays
|
|
4395
|
-
* registered across a reconnect, and a reload keeps its salt and its views,
|
|
4396
|
-
* so their older edges still name an earlier socket. `sessionIds` are those
|
|
4397
|
-
* earlier sockets. The current one is matched by `session::id()` itself,
|
|
4398
|
-
* which SurrealDB evaluates in the subscriber's own session, so the filter
|
|
4399
|
-
* holds before the client has learned its new id.
|
|
4400
|
-
*
|
|
4401
|
-
* The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
|
|
4402
|
-
* and without it nothing matches. Per-user tables need no filter.
|
|
4403
|
-
*/
|
|
4404
|
-
function listRefLive(table, opts) {
|
|
4405
|
-
const join = opts.inlineBodies ? " FETCH out" : "";
|
|
4406
|
-
if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
|
|
4407
|
-
return {
|
|
4408
|
-
sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
|
|
4409
|
-
vars: { sids: [...opts.sessionIds] }
|
|
4410
|
-
};
|
|
4411
|
-
}
|
|
4412
|
-
/** Register + read back edges/meta/children in ONE request. */
|
|
4413
|
-
const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
4414
|
-
const registerVars = (payload) => ({
|
|
4415
|
-
config: payload,
|
|
4416
|
-
in: payload.id
|
|
4417
|
-
});
|
|
4418
|
-
/** One heartbeat statement per view id; result index i answers id i. */
|
|
4419
|
-
function heartbeatBatch(ids) {
|
|
4420
|
-
const vars = {};
|
|
4421
|
-
return {
|
|
4422
|
-
sql: ids.map((id, i) => {
|
|
4423
|
-
vars[`id${i}`] = id;
|
|
4424
|
-
return `fn::query::heartbeat($id${i})`;
|
|
4425
|
-
}).join(";\n"),
|
|
4426
|
-
vars
|
|
4693
|
+
error: void 0,
|
|
4694
|
+
everConnected
|
|
4695
|
+
},
|
|
4696
|
+
recoveredNow: recovered
|
|
4697
|
+
};
|
|
4698
|
+
}
|
|
4699
|
+
const consecutiveFailures = input.consecutiveFailures + 1;
|
|
4700
|
+
const kind = classifySyncError(error);
|
|
4701
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4702
|
+
if (input.health.status !== "degraded" && consecutiveFailures >= degradeAfter) return {
|
|
4703
|
+
...base,
|
|
4704
|
+
consecutiveFailures,
|
|
4705
|
+
health: {
|
|
4706
|
+
...input.health,
|
|
4707
|
+
status: "degraded",
|
|
4708
|
+
consecutiveFailures,
|
|
4709
|
+
kind,
|
|
4710
|
+
error: message
|
|
4711
|
+
},
|
|
4712
|
+
degradedNow: true
|
|
4427
4713
|
};
|
|
4428
|
-
}
|
|
4429
|
-
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
4430
|
-
const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
|
|
4431
|
-
const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
|
|
4432
|
-
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4433
|
-
/**
|
|
4434
|
-
* The pagehide release: one bare statement for `beaconSql`, which has no params
|
|
4435
|
-
* channel, so ids are inlined. Only shapes the client minted itself get in, a
|
|
4436
|
-
* `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
|
|
4437
|
-
* interpolated. `null` when there is nothing the server could act on: with no
|
|
4438
|
-
* session ids it could not tell this page's entries from anyone else's.
|
|
4439
|
-
*/
|
|
4440
|
-
function leaveViewsBeacon(ids, sessionIds) {
|
|
4441
|
-
const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
|
|
4442
|
-
const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
|
|
4443
|
-
if (list.length === 0 || sids.length === 0) return null;
|
|
4444
|
-
const sidList = sids.map((sid) => `'${sid}'`).join(", ");
|
|
4445
|
-
return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
|
|
4446
|
-
}
|
|
4447
|
-
const bodySelect = () => "SELECT * FROM $ids";
|
|
4448
|
-
/** Local transaction that MERGEs many fetched bodies. */
|
|
4449
|
-
function upsertBodiesTx(records) {
|
|
4450
|
-
const vars = {};
|
|
4451
|
-
const stmts = records.map((r, i) => {
|
|
4452
|
-
vars[`id${i}`] = r.id;
|
|
4453
|
-
vars[`content${i}`] = r.content;
|
|
4454
|
-
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
4455
|
-
});
|
|
4456
4714
|
return {
|
|
4457
|
-
|
|
4458
|
-
|
|
4715
|
+
...base,
|
|
4716
|
+
consecutiveFailures,
|
|
4717
|
+
health: {
|
|
4718
|
+
...input.health,
|
|
4719
|
+
consecutiveFailures,
|
|
4720
|
+
kind,
|
|
4721
|
+
error: message
|
|
4722
|
+
}
|
|
4459
4723
|
};
|
|
4460
4724
|
}
|
|
4725
|
+
const selfHealDelayMs = (attempt) => Math.min(SELF_HEAL_MAX_MS, SELF_HEAL_BASE_MS * 2 ** Math.min(attempt, 30));
|
|
4461
4726
|
|
|
4462
4727
|
//#endregion
|
|
4463
4728
|
//#region src/query/membership.saga.ts
|
|
@@ -4514,6 +4779,7 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
|
|
|
4514
4779
|
}
|
|
4515
4780
|
const wasAuthoritative = isAuthoritative(entry.lifecycle);
|
|
4516
4781
|
yield fx.state.update(commitMembership(hash, remoteArray, meta?.present ?? true));
|
|
4782
|
+
yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
|
|
4517
4783
|
if (!wasAuthoritative) yield fx.emit({
|
|
4518
4784
|
type: "query:authority",
|
|
4519
4785
|
hash,
|
|
@@ -4560,6 +4826,7 @@ function* applySubqueryChildren(hash, children) {
|
|
|
4560
4826
|
const entry = yield fx.state.read((s) => s.queries.get(hash));
|
|
4561
4827
|
if (!entry || recordVersionArraysEqual(entry.subqueryRemoteArray, children)) return;
|
|
4562
4828
|
yield fx.state.update(setSubqueryRemoteArray(hash, children));
|
|
4829
|
+
yield* forgetDeparted(hash, departedIds(entry.subqueryRemoteArray, children));
|
|
4563
4830
|
if (entry.lifecycle.phase !== "cold") try {
|
|
4564
4831
|
yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), { children }, "merge");
|
|
4565
4832
|
} catch (err) {
|
|
@@ -4933,196 +5200,6 @@ function* registerRemote(env, hash, retry = false) {
|
|
|
4933
5200
|
}
|
|
4934
5201
|
}
|
|
4935
5202
|
|
|
4936
|
-
//#endregion
|
|
4937
|
-
//#region src/query/lifecycle.saga.ts
|
|
4938
|
-
/**
|
|
4939
|
-
* One tick for every query in state: evict the ones nobody has watched for a
|
|
4940
|
-
* ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
|
|
4941
|
-
* itself at half the shortest ttl.
|
|
4942
|
-
*/
|
|
4943
|
-
function* lifecycleTick(env) {
|
|
4944
|
-
const now = yield fx.now();
|
|
4945
|
-
const state = yield fx.state.read((s) => s);
|
|
4946
|
-
for (const hash of evictable(state, now)) yield* evictQuery(hash);
|
|
4947
|
-
const remaining = yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered"));
|
|
4948
|
-
if (remaining.length > 0) {
|
|
4949
|
-
const beat = heartbeatBatch(remaining.map((e) => e.def.id));
|
|
4950
|
-
try {
|
|
4951
|
-
const results = yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs);
|
|
4952
|
-
let reclaimed = 0;
|
|
4953
|
-
for (let i = 0; i < remaining.length; i++) {
|
|
4954
|
-
const r = results[i];
|
|
4955
|
-
if (r?.status === "OK" && heartbeatRowGone(r.result)) {
|
|
4956
|
-
reclaimed++;
|
|
4957
|
-
yield fx.state.update(applyLifecycle(remaining[i].def.hash, { type: "remote-dropped" }));
|
|
4958
|
-
}
|
|
4959
|
-
}
|
|
4960
|
-
yield fx.state.update(stampHeartbeat(remaining.map((e) => e.def.hash), now));
|
|
4961
|
-
if (reclaimed > 0) {
|
|
4962
|
-
yield fx.emit({
|
|
4963
|
-
type: "log",
|
|
4964
|
-
level: "warn",
|
|
4965
|
-
message: "query rows reclaimed by the server; re-registering",
|
|
4966
|
-
data: { reclaimed }
|
|
4967
|
-
});
|
|
4968
|
-
yield fx.dispatch({ type: "EnsureRegistered" });
|
|
4969
|
-
}
|
|
4970
|
-
yield fx.dispatch({
|
|
4971
|
-
type: "SyncOutcome",
|
|
4972
|
-
ok: true
|
|
4973
|
-
});
|
|
4974
|
-
} catch (error) {
|
|
4975
|
-
yield fx.dispatch({
|
|
4976
|
-
type: "SyncOutcome",
|
|
4977
|
-
ok: false,
|
|
4978
|
-
error
|
|
4979
|
-
});
|
|
4980
|
-
}
|
|
4981
|
-
}
|
|
4982
|
-
const ttl = yield fx.state.read((s) => shortestTtlMs(s));
|
|
4983
|
-
yield fx.timer.set("lifecycle", Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: "LifecycleTick" });
|
|
4984
|
-
}
|
|
4985
|
-
/** Free a query's local view and forget it. The server row expires by TTL. */
|
|
4986
|
-
function* evictQuery(hash) {
|
|
4987
|
-
if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
|
|
4988
|
-
try {
|
|
4989
|
-
yield fx.ssp.unregister(hash);
|
|
4990
|
-
} catch (error) {
|
|
4991
|
-
yield fx.emit({
|
|
4992
|
-
type: "log",
|
|
4993
|
-
level: "debug",
|
|
4994
|
-
message: "unregister failed",
|
|
4995
|
-
data: {
|
|
4996
|
-
hash,
|
|
4997
|
-
error
|
|
4998
|
-
}
|
|
4999
|
-
});
|
|
5000
|
-
}
|
|
5001
|
-
yield fx.state.update(removeQuery(hash));
|
|
5002
|
-
yield fx.emit({
|
|
5003
|
-
type: "query:evicted",
|
|
5004
|
-
hash
|
|
5005
|
-
});
|
|
5006
|
-
}
|
|
5007
|
-
/** Drop acked outbox items membership never named within the grace window. */
|
|
5008
|
-
function* ackPrune() {
|
|
5009
|
-
const now = yield fx.now();
|
|
5010
|
-
yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
|
|
5011
|
-
if (yield fx.state.read((s) => s.outbox.some((i) => i.status === "acked"))) yield fx.timer.set("ack-prune", ACK_GRACE_MS, { type: "AckPrune" });
|
|
5012
|
-
}
|
|
5013
|
-
/** Every `_00_view` row, parsed. Throws when the read fails. */
|
|
5014
|
-
function* readViews() {
|
|
5015
|
-
const res = yield fx.local.query(readViewRows());
|
|
5016
|
-
return parseViewIndex(Array.isArray(res) ? res[0] : void 0);
|
|
5017
|
-
}
|
|
5018
|
-
const indexOf = (rows) => new Map(rows.map((r) => [r.key, r.ids]));
|
|
5019
|
-
/**
|
|
5020
|
-
* Load the durable view index (boot, bucket switch). A failed read leaves it
|
|
5021
|
-
* empty, which fails closed: cold scans paint nothing until the server answers.
|
|
5022
|
-
*/
|
|
5023
|
-
function* loadViews() {
|
|
5024
|
-
try {
|
|
5025
|
-
yield fx.state.update(reloadViews(indexOf(yield* readViews())));
|
|
5026
|
-
} catch (error) {
|
|
5027
|
-
yield fx.emit({
|
|
5028
|
-
type: "log",
|
|
5029
|
-
level: "warn",
|
|
5030
|
-
message: "view index read failed",
|
|
5031
|
-
data: { error }
|
|
5032
|
-
});
|
|
5033
|
-
}
|
|
5034
|
-
}
|
|
5035
|
-
const tableOf$1 = (id) => id.slice(0, id.indexOf(":"));
|
|
5036
|
-
/**
|
|
5037
|
-
* Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
|
|
5038
|
-
* `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
|
|
5039
|
-
* rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
|
|
5040
|
-
* nothing retains (see `retained`) from the store and the circuit, a chunk at
|
|
5041
|
-
* a time. Only a leader or solo tab deletes, and a leader relays the deletes
|
|
5042
|
-
* so followers drop the rows too; a follower only refreshes its index.
|
|
5043
|
-
*/
|
|
5044
|
-
function* gcTick() {
|
|
5045
|
-
yield fx.state.wait((s) => s.primed);
|
|
5046
|
-
try {
|
|
5047
|
-
const rows = yield* readViews();
|
|
5048
|
-
const [role, bucket, held] = yield fx.state.read((s) => [
|
|
5049
|
-
s.tabRole,
|
|
5050
|
-
s.bucketId,
|
|
5051
|
-
new Set([...s.queries.values()].map((e) => e.def.viewKey))
|
|
5052
|
-
]);
|
|
5053
|
-
const now = yield fx.now();
|
|
5054
|
-
const expired = role === "follower" ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
|
|
5055
|
-
const retired = /* @__PURE__ */ new Set();
|
|
5056
|
-
for (let i = 0; i < expired.length; i += GC_CHUNK) {
|
|
5057
|
-
const chunk = expired.slice(i, i + GC_CHUNK);
|
|
5058
|
-
const settled = yield fx.all(chunk.map((r) => fx.local.delete(VIEW_TABLE, viewRecordId(r.key))));
|
|
5059
|
-
chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
|
|
5060
|
-
}
|
|
5061
|
-
yield fx.state.update(reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
|
|
5062
|
-
if (role === "follower") return;
|
|
5063
|
-
const removed = yield* collectOrphans(bucket, role === "leader");
|
|
5064
|
-
yield fx.emit({
|
|
5065
|
-
type: "log",
|
|
5066
|
-
level: "info",
|
|
5067
|
-
message: "orphan gc done",
|
|
5068
|
-
data: {
|
|
5069
|
-
removed,
|
|
5070
|
-
retiredViews: retired.size
|
|
5071
|
-
}
|
|
5072
|
-
});
|
|
5073
|
-
} catch (error) {
|
|
5074
|
-
yield fx.emit({
|
|
5075
|
-
type: "log",
|
|
5076
|
-
level: "warn",
|
|
5077
|
-
message: "orphan gc failed",
|
|
5078
|
-
data: { error }
|
|
5079
|
-
});
|
|
5080
|
-
} finally {
|
|
5081
|
-
yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
|
|
5082
|
-
}
|
|
5083
|
-
}
|
|
5084
|
-
/**
|
|
5085
|
-
* Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
|
|
5086
|
-
* re-checked against fresh state (a membership committed meanwhile may name
|
|
5087
|
-
* an id again) and the sweep stops if the bucket moved under it.
|
|
5088
|
-
*/
|
|
5089
|
-
function* collectOrphans(bucket, relay) {
|
|
5090
|
-
const candidates = yield fx.state.read((s) => {
|
|
5091
|
-
const keep = retained(s);
|
|
5092
|
-
return [...s.versions.keys()].filter((id) => !keep(id));
|
|
5093
|
-
});
|
|
5094
|
-
let removed = 0;
|
|
5095
|
-
for (let i = 0; i < candidates.length; i += GC_CHUNK) {
|
|
5096
|
-
const chunk = yield fx.state.read((s) => {
|
|
5097
|
-
if (!s.primed || s.bucketId !== bucket) return null;
|
|
5098
|
-
const keep = retained(s);
|
|
5099
|
-
return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
|
|
5100
|
-
});
|
|
5101
|
-
if (chunk === null) break;
|
|
5102
|
-
const settled = yield fx.all(chunk.map((id) => fx.local.delete(tableOf$1(id), id)));
|
|
5103
|
-
const done = chunk.filter((_, j) => settled[j].ok);
|
|
5104
|
-
if (done.length === 0) continue;
|
|
5105
|
-
const records = done.map((id) => ({
|
|
5106
|
-
table: tableOf$1(id),
|
|
5107
|
-
op: "DELETE",
|
|
5108
|
-
id,
|
|
5109
|
-
record: {}
|
|
5110
|
-
}));
|
|
5111
|
-
yield fx.state.update(deleteVersions(done));
|
|
5112
|
-
yield fx.ssp.ingest(records);
|
|
5113
|
-
if (relay) yield fx.emit({
|
|
5114
|
-
type: "tabs:broadcast",
|
|
5115
|
-
message: {
|
|
5116
|
-
type: "ingest",
|
|
5117
|
-
records
|
|
5118
|
-
}
|
|
5119
|
-
});
|
|
5120
|
-
removed += done.length;
|
|
5121
|
-
}
|
|
5122
|
-
if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
|
|
5123
|
-
return removed;
|
|
5124
|
-
}
|
|
5125
|
-
|
|
5126
5203
|
//#endregion
|
|
5127
5204
|
//#region src/mutation/write.saga.ts
|
|
5128
5205
|
const debounceKey = (input) => {
|
|
@@ -6291,7 +6368,17 @@ function renderIdSetSelect(surql, kw, idsParam) {
|
|
|
6291
6368
|
].filter((n) => n !== null && n > orderByIndex);
|
|
6292
6369
|
orderBy = " " + surql.slice(orderByIndex, Math.min(...ends)).trim();
|
|
6293
6370
|
}
|
|
6294
|
-
return `${selectClause} FROM
|
|
6371
|
+
return `${selectClause} FROM ${idSetSource(selectClause, idsParam, orderBy !== "")}${orderBy}`;
|
|
6372
|
+
}
|
|
6373
|
+
/**
|
|
6374
|
+
* The FROM source for an id-set read. The in-browser SurrealDB (wasm, 3.x)
|
|
6375
|
+
* reads bare record ids without loading them: a field list over them throws
|
|
6376
|
+
* ("Specify a database to use") and an ORDER BY is silently ignored, while a
|
|
6377
|
+
* plain `SELECT *` works. Those read from `(SELECT * FROM $ids)` instead, the
|
|
6378
|
+
* same rows as documents. The SQLite translator accepts both shapes.
|
|
6379
|
+
*/
|
|
6380
|
+
function idSetSource(selectClause, idsParam, ordered = false) {
|
|
6381
|
+
return !ordered && /^SELECT\s+(VALUE\s+)?\*/i.test(selectClause.trim()) ? `$${idsParam}` : `(SELECT * FROM $${idsParam})`;
|
|
6295
6382
|
}
|
|
6296
6383
|
/**
|
|
6297
6384
|
* Restrict any plan's base rows to an explicit id-set, regardless of offset.
|
|
@@ -7396,9 +7483,8 @@ var Runtime = class {
|
|
|
7396
7483
|
});
|
|
7397
7484
|
}
|
|
7398
7485
|
for (const [hash, subs] of this.statusSubs) {
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
const status = deriveStatus(entry.lifecycle);
|
|
7486
|
+
if (!next.queries.get(hash)) continue;
|
|
7487
|
+
const status = queryStatus(next, hash) ?? "idle";
|
|
7402
7488
|
if (this.lastStatus.get(hash) === status) continue;
|
|
7403
7489
|
this.lastStatus.set(hash, status);
|
|
7404
7490
|
for (const cb of subs) this.safely(() => cb(status));
|
|
@@ -7502,9 +7588,8 @@ var Runtime = class {
|
|
|
7502
7588
|
const set = this.statusSubs.get(hash) ?? /* @__PURE__ */ new Set();
|
|
7503
7589
|
set.add(cb);
|
|
7504
7590
|
this.statusSubs.set(hash, set);
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
const status = deriveStatus(entry.lifecycle);
|
|
7591
|
+
if (this.stateValue.queries.get(hash)) {
|
|
7592
|
+
const status = queryStatus(this.stateValue, hash) ?? "idle";
|
|
7508
7593
|
this.lastStatus.set(hash, status);
|
|
7509
7594
|
if (options.immediate) this.safely(() => cb(status));
|
|
7510
7595
|
}
|
|
@@ -9116,7 +9201,7 @@ var SurrealCacheEngine = class extends LocalDatabaseService {
|
|
|
9116
9201
|
}
|
|
9117
9202
|
async selectByIds(table, ids, opts) {
|
|
9118
9203
|
if (ids.length === 0) return [];
|
|
9119
|
-
let sql = `SELECT ${opts?.select && opts.select.length > 0 ? ["id", ...opts.select].join(", ") : "*"} FROM $__ids`;
|
|
9204
|
+
let sql = `SELECT ${opts?.select && opts.select.length > 0 ? ["id", ...opts.select].join(", ") : "*"} FROM (SELECT * FROM $__ids)`;
|
|
9120
9205
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(", ")}`;
|
|
9121
9206
|
const [rows] = await this.query(`${sql};`, { __ids: ids });
|
|
9122
9207
|
const result = rows ?? [];
|
|
@@ -9291,12 +9376,12 @@ function translateStatement(stmt, vars) {
|
|
|
9291
9376
|
value
|
|
9292
9377
|
};
|
|
9293
9378
|
}
|
|
9294
|
-
m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
|
|
9379
|
+
m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+(?:\$(\w+)|\(\s*SELECT\s+\*\s+FROM\s+\$(\w+)\s*\))$/i.exec(head);
|
|
9295
9380
|
if (m) {
|
|
9296
9381
|
const value = m[1] ? m[2].trim() : void 0;
|
|
9297
9382
|
return {
|
|
9298
9383
|
kind: "selectByIds",
|
|
9299
|
-
ids: vars[m[3]] ?? [],
|
|
9384
|
+
ids: vars[m[3] ?? m[4]] ?? [],
|
|
9300
9385
|
select: projFields(m[2], !!m[1]),
|
|
9301
9386
|
value,
|
|
9302
9387
|
limit,
|
|
@@ -12469,14 +12554,17 @@ const MAX_WAIT_MS = 2e3;
|
|
|
12469
12554
|
const PERSIST_DEBOUNCE_MS = 400;
|
|
12470
12555
|
const PRESENCE_THROTTLE_MS = 250;
|
|
12471
12556
|
const CLAIM_RETRY_MS = 5e3;
|
|
12472
|
-
/**
|
|
12473
|
-
*
|
|
12474
|
-
|
|
12557
|
+
/** Longest an open or close waits on the server (the first answer for a
|
|
12558
|
+
* field with nothing local, the last edits going out) before going on
|
|
12559
|
+
* without it. Offline it does not wait at all. */
|
|
12560
|
+
const SERVER_WAIT_MS = 4e3;
|
|
12475
12561
|
/** Pause after a fold is noticed before checking the published document. */
|
|
12476
12562
|
const VERIFY_DELAY_MS = 1e3;
|
|
12477
12563
|
var CrdtManager = class {
|
|
12478
12564
|
sessions = /* @__PURE__ */ new Map();
|
|
12479
12565
|
opening = /* @__PURE__ */ new Map();
|
|
12566
|
+
/** Closes still saving their field's local state, by session key. */
|
|
12567
|
+
saving = /* @__PURE__ */ new Map();
|
|
12480
12568
|
ports = null;
|
|
12481
12569
|
logger;
|
|
12482
12570
|
peer = randomPeer();
|
|
@@ -12532,7 +12620,7 @@ var CrdtManager = class {
|
|
|
12532
12620
|
session = await inFlight;
|
|
12533
12621
|
session.refs++;
|
|
12534
12622
|
} else {
|
|
12535
|
-
const pending = this.start(key, table, recordId, field, kind, cursor).finally(() => this.opening.delete(key));
|
|
12623
|
+
const pending = this.start(key, table, recordId, field, kind, cursor, !!draft).finally(() => this.opening.delete(key));
|
|
12536
12624
|
this.opening.set(key, pending);
|
|
12537
12625
|
session = await pending;
|
|
12538
12626
|
}
|
|
@@ -12558,7 +12646,8 @@ var CrdtManager = class {
|
|
|
12558
12646
|
this.scheduleFlush(s, 0);
|
|
12559
12647
|
}
|
|
12560
12648
|
/** Close one open. The last close of a field resolves once its unsent
|
|
12561
|
-
* edits are in the outbox
|
|
12649
|
+
* edits are in the outbox, or once they are saved on the device when the
|
|
12650
|
+
* server does not answer in time (offline: at once). */
|
|
12562
12651
|
close(table, recordId, field) {
|
|
12563
12652
|
const s = this.sessions.get(`${table}|${recordId}|${field}`);
|
|
12564
12653
|
if (!s || --s.refs > 0) return Promise.resolve();
|
|
@@ -12581,10 +12670,11 @@ var CrdtManager = class {
|
|
|
12581
12670
|
cursor: !!col.cursor
|
|
12582
12671
|
};
|
|
12583
12672
|
}
|
|
12584
|
-
async start(key, table, recordId, field, kind, cursor) {
|
|
12673
|
+
async start(key, table, recordId, field, kind, cursor, fresh = false) {
|
|
12585
12674
|
if (!this.ports) throw new Error("CrdtManager: not attached to a client");
|
|
12586
12675
|
const doc = new (await (loadLoro())).LoroDoc();
|
|
12587
12676
|
doc.setPeerId(this.peer);
|
|
12677
|
+
await this.saving.get(key);
|
|
12588
12678
|
const local = await this.readLocal(recordId, field);
|
|
12589
12679
|
if (local.snapshot) this.tryImport(doc, local.snapshot, key);
|
|
12590
12680
|
const session = {
|
|
@@ -12616,7 +12706,7 @@ var CrdtManager = class {
|
|
|
12616
12706
|
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, { pushPresence: (state) => this.pushPresence(session, state) });
|
|
12617
12707
|
this.sessions.set(key, session);
|
|
12618
12708
|
this.ensureClaimed();
|
|
12619
|
-
if (!local.snapshot) await this.refetchCanonical(session);
|
|
12709
|
+
if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
|
|
12620
12710
|
session.disposers.push(doc.subscribeLocalUpdates(() => {
|
|
12621
12711
|
session.local.peers[this.peer] ??= { enqueued: 0 };
|
|
12622
12712
|
this.scheduleFlush(session);
|
|
@@ -12637,7 +12727,7 @@ var CrdtManager = class {
|
|
|
12637
12727
|
}, (rows) => this.ingestPresence(session, rows));
|
|
12638
12728
|
session.disposers.push(presence.stop);
|
|
12639
12729
|
}
|
|
12640
|
-
if (!session.handle.hasContent()) await
|
|
12730
|
+
if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
|
|
12641
12731
|
await session.ingesting;
|
|
12642
12732
|
this.scheduleFlush(session, 0);
|
|
12643
12733
|
this.logger.debug({
|
|
@@ -12658,10 +12748,23 @@ var CrdtManager = class {
|
|
|
12658
12748
|
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
12659
12749
|
if (s.presenceSent) this.dropPresence(s);
|
|
12660
12750
|
if (flush) {
|
|
12661
|
-
|
|
12662
|
-
|
|
12751
|
+
const saved = this.persist(s);
|
|
12752
|
+
this.saving.set(s.key, saved);
|
|
12753
|
+
await saved;
|
|
12754
|
+
if (this.saving.get(s.key) === saved) this.saving.delete(s.key);
|
|
12755
|
+
await this.serverWait(this.flush(s));
|
|
12663
12756
|
}
|
|
12664
12757
|
}
|
|
12758
|
+
/**
|
|
12759
|
+
* Wait for server-bound `work`, at most {@link SERVER_WAIT_MS}, and not at
|
|
12760
|
+
* all while disconnected: a remote call made then queues behind the
|
|
12761
|
+
* reconnect for up to the query timeout, and an offline "new thread" form
|
|
12762
|
+
* must not hang on it. The work itself goes on either way.
|
|
12763
|
+
*/
|
|
12764
|
+
serverWait(work) {
|
|
12765
|
+
if (this.remote.getStatus() !== "connected") return Promise.resolve();
|
|
12766
|
+
return Promise.race([work, delay(SERVER_WAIT_MS)]);
|
|
12767
|
+
}
|
|
12665
12768
|
async ingest(s, rows) {
|
|
12666
12769
|
const loro = await loadLoro();
|
|
12667
12770
|
const ids = new Set(rows.map((row) => String(row.id ?? "")));
|
|
@@ -15261,7 +15364,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15261
15364
|
return new TabsCoordinator({
|
|
15262
15365
|
tabId: s.tabId,
|
|
15263
15366
|
fingerprint: computeTabsFingerprint({
|
|
15264
|
-
coreVersion: "0.0.1-canary.
|
|
15367
|
+
coreVersion: "0.0.1-canary.327",
|
|
15265
15368
|
schemaHash: hash53(config.schemaSurql),
|
|
15266
15369
|
endpoint: config.database.endpoint ?? "",
|
|
15267
15370
|
namespace: config.database.namespace,
|
|
@@ -16367,7 +16470,7 @@ var Sp00kyClient = class {
|
|
|
16367
16470
|
return this.services.crdt.createDraft(table, field);
|
|
16368
16471
|
}
|
|
16369
16472
|
/** Close a `@crdt` field. The last close resolves once its unsent edits
|
|
16370
|
-
* are queued in the outbox. */
|
|
16473
|
+
* are queued in the outbox; offline, once they are saved on the device. */
|
|
16371
16474
|
closeCrdtField(table, recordId, field) {
|
|
16372
16475
|
return this.services.crdt.close(table, recordId, field);
|
|
16373
16476
|
}
|