@spooky-sync/core 0.0.1-canary.286 → 0.0.1-canary.287

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 CHANGED
@@ -55,6 +55,7 @@ re-materialized from state, never patched in place.
55
55
  - **Raw remote calls are gated by the query allowlist.** When the generated schema carries `policy.queryAllowlist: 'warn' | 'enforce'` (from `sync.queryAllowlist` in `sp00ky.yml`), `remoteQuery`, `useRemote` and `queryRaw` throw unless the client config sets `allowRawRemote: true`; builder queries are unaffected. A registration the SSP refuses as not allowlisted (403 `not_allowlisted`) is marked `remote: 'failed'` after one attempt with an error-level log carrying the surql and a `spky generate` hint; the view stays cold (`membershipKnown` false), it is never read as confirmed empty.
56
56
  - **`@parent` columns are auto-populated.** Defined in the `.surql` schema as `record<user>` with the `-- @parent` annotation; never write them yourself — they're set from the auth context server-side.
57
57
  - **`db.preload` is a registered query.** Resolved before on this device (a `_00_view` row exists) it returns at once; never resolved it waits until the server's membership and every body are local. It holds no subscriber, so it is evicted a ttl after registration unless a view mounts the same query. `refresh`/`staleTime` are ignored; pass `signal` to abort a cold wait.
58
+ - **A never-resolved query paints only vouched rows.** Before the server answers (`cold`), the local predicate scan shows a cached body only if some `_00_view` row names it (as a member or subquery child) or a write for it is in flight. A body no view names is an orphan (deleted upstream while nothing watched it): it stays hidden, and the GC deletes it 30 s after boot and then hourly. A `_00_view` row no query has resolved for 14 days is retired by the same sweep, so that query paints cold again.
58
59
  - **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued.
59
60
 
60
61
  ## Module map (under `src/`)
package/dist/index.d.ts CHANGED
@@ -1311,6 +1311,12 @@ interface ClientState {
1311
1311
  readonly membershipReread: ReadonlyMap<QueryHash, number>;
1312
1312
  /** Local body versions (`_00_rv`) by encoded record id. */
1313
1313
  readonly versions: ReadonlyMap<string, number>;
1314
+ /**
1315
+ * What each durable `_00_view` row vouches for, by view key: its members
1316
+ * and subquery children. A body no row names is an orphan: it never paints
1317
+ * before the server answers and the GC deletes it.
1318
+ */
1319
+ readonly views: ReadonlyMap<string, ReadonlyArray<string>>;
1314
1320
  readonly outbox: ReadonlyArray<OutboxItem>;
1315
1321
  readonly pendingWrites: ReadonlyMap<string, PendingWrite>;
1316
1322
  readonly failedCount: number;
package/dist/index.js CHANGED
@@ -1752,8 +1752,8 @@ function selfAllowlistedVariant(flag, userId) {
1752
1752
 
1753
1753
  //#endregion
1754
1754
  //#region src/modules/devtools/index.ts
1755
- const CORE_VERSION = "0.0.1-canary.286";
1756
- const WASM_VERSION = "0.0.1-canary.286";
1755
+ const CORE_VERSION = "0.0.1-canary.287";
1756
+ const WASM_VERSION = "0.0.1-canary.287";
1757
1757
  const SURREAL_VERSION = "3.0.3";
1758
1758
  var DevToolsService = class DevToolsService {
1759
1759
  eventsHistory = [];
@@ -2438,8 +2438,21 @@ const AUTH_READY_RETRY_MS = 500;
2438
2438
  const AUTH_READY_MAX_ATTEMPTS = 10;
2439
2439
  /** Heartbeat at this fraction of the shortest ttl in state. */
2440
2440
  const TTL_HEARTBEAT_FRACTION = .5;
2441
- /** Orphan body garbage collection cadence. */
2442
- const GC_INTERVAL_MS = 10080 * 60 * 1e3;
2441
+ /**
2442
+ * Orphan body garbage collection: the first sweep waits this long after boot
2443
+ * (off the first paint and the first sync round), then repeats on the interval.
2444
+ */
2445
+ const GC_BOOT_DELAY_MS = 3e4;
2446
+ const GC_INTERVAL_MS = 3600 * 1e3;
2447
+ /** Ids deleted per GC step. */
2448
+ const GC_CHUNK = 200;
2449
+ /**
2450
+ * A `_00_view` row no query has resolved for this long stops vouching for its
2451
+ * ids (the sweep deletes it, so its bodies can be collected). Each session
2452
+ * that resolves a query rewrites its row, so this only retires query shapes
2453
+ * the app no longer runs.
2454
+ */
2455
+ const VIEW_RETENTION_MS = 336 * 60 * 60 * 1e3;
2443
2456
  /** Rolling telemetry sample window per query. */
2444
2457
  const TELEMETRY_SAMPLE_WINDOW = 100;
2445
2458
  function backoffMs(attempt, base = RETRY_BASE_MS, max = RETRY_MAX_MS) {
@@ -2512,6 +2525,32 @@ const setServerState = (hash, serverState) => (s) => withEntry(s, hash, (e) => e
2512
2525
  * set names: membership has caught up with the write, the overlay's job is
2513
2526
  * done (invariant I7).
2514
2527
  */
2528
+ /** What a `_00_view` row holding these arrays vouches for. */
2529
+ const vouchedIds = (members, children) => [...new Set([...members.map(([id]) => id), ...children.map(([id]) => id)])];
2530
+ const withView = (s, viewKey, ids) => {
2531
+ const views = new Map(s.views);
2532
+ views.set(viewKey, ids);
2533
+ return {
2534
+ ...s,
2535
+ views
2536
+ };
2537
+ };
2538
+ /**
2539
+ * Replace the durable view index with a fresh `_00_view` read (boot, bucket
2540
+ * switch, GC). An entry of a query in state is kept: its commits update the
2541
+ * index as they write the row, so it is never older than the read.
2542
+ */
2543
+ const reloadViews = (fromStore) => (s) => {
2544
+ const views = new Map(fromStore);
2545
+ for (const e of s.queries.values()) {
2546
+ const held = s.views.get(e.def.viewKey);
2547
+ if (held) views.set(e.def.viewKey, held);
2548
+ }
2549
+ return {
2550
+ ...s,
2551
+ views
2552
+ };
2553
+ };
2515
2554
  const commitMembership = (hash, remoteArray, present) => (s) => {
2516
2555
  const withArray = withEntry(s, hash, (e) => ({
2517
2556
  ...e,
@@ -2522,10 +2561,11 @@ const commitMembership = (hash, remoteArray, present) => (s) => {
2522
2561
  })
2523
2562
  }));
2524
2563
  if (withArray === s) return s;
2564
+ const entry = withArray.queries.get(hash);
2525
2565
  const named = new Set(remoteArray.map(([id]) => id));
2526
2566
  const outbox = withArray.outbox.filter((item) => !(item.status === "acked" && named.has(item.recordId)));
2527
2567
  return {
2528
- ...withArray,
2568
+ ...withView(withArray, entry.def.viewKey, vouchedIds(remoteArray, entry.subqueryRemoteArray)),
2529
2569
  outbox,
2530
2570
  dirty: addAll(withArray.dirty, [hash])
2531
2571
  };
@@ -2540,10 +2580,19 @@ const setLocalArray = (hash, localArray) => (s) => {
2540
2580
  dirty: addAll(next.dirty, [hash])
2541
2581
  };
2542
2582
  };
2543
- const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => withEntry(s, hash, (e) => ({
2544
- ...e,
2545
- subqueryRemoteArray
2546
- }));
2583
+ /**
2584
+ * Replace a query's subquery child set. A query past `cold` has a `_00_view`
2585
+ * row, which records the children too (see `applySubqueryChildren`).
2586
+ */
2587
+ const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => {
2588
+ const next = withEntry(s, hash, (e) => ({
2589
+ ...e,
2590
+ subqueryRemoteArray
2591
+ }));
2592
+ const entry = next.queries.get(hash);
2593
+ if (!entry || entry.lifecycle.phase === "cold") return next;
2594
+ return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
2595
+ };
2547
2596
  const setRecords = (hash, records, changed, materializeMs) => (s) => {
2548
2597
  const next = withEntry(s, hash, (e) => {
2549
2598
  const prev = e.telemetry.phaseSamples.localFetch ?? [];
@@ -2844,6 +2893,7 @@ const rebindQuery = (hash, next) => (s) => {
2844
2893
  const clearBucketState = () => (s) => ({
2845
2894
  ...s,
2846
2895
  versions: /* @__PURE__ */ new Map(),
2896
+ views: /* @__PURE__ */ new Map(),
2847
2897
  outbox: [],
2848
2898
  pendingWrites: /* @__PURE__ */ new Map(),
2849
2899
  dirty: /* @__PURE__ */ new Set(),
@@ -2903,6 +2953,35 @@ function overlay(s) {
2903
2953
  deletes
2904
2954
  };
2905
2955
  }
2956
+ const vouchedCache = /* @__PURE__ */ new WeakMap();
2957
+ /**
2958
+ * Every id some durable `_00_view` row vouches for. Memoized on the index,
2959
+ * which only changes when a view row does.
2960
+ */
2961
+ function vouched(s) {
2962
+ const hit = vouchedCache.get(s.views);
2963
+ if (hit) return hit;
2964
+ const out = /* @__PURE__ */ new Set();
2965
+ for (const ids of s.views.values()) for (const id of ids) out.add(id);
2966
+ vouchedCache.set(s.views, out);
2967
+ return out;
2968
+ }
2969
+ /**
2970
+ * Bodies the GC must keep: `_00_` bookkeeping, anything a durable view
2971
+ * vouches for, anything a query in memory names (members or children), and
2972
+ * anything a write is still in flight for.
2973
+ */
2974
+ function retained(s) {
2975
+ const held = /* @__PURE__ */ new Set();
2976
+ for (const e of s.queries.values()) {
2977
+ for (const [id] of e.remoteArray) held.add(id);
2978
+ for (const [id] of e.subqueryRemoteArray) held.add(id);
2979
+ }
2980
+ for (const item of s.outbox) held.add(item.recordId);
2981
+ for (const write of s.pendingWrites.values()) held.add(write.recordId);
2982
+ const known = vouched(s);
2983
+ return (id) => id.startsWith("_00_") || known.has(id) || held.has(id);
2984
+ }
2906
2985
  const pendingDeleteIds = (s) => new Set(s.outbox.filter((i) => i.type === "delete").map((i) => i.recordId));
2907
2986
  const hasAckedWrites = (s) => s.outbox.some((i) => i.status === "acked");
2908
2987
  const pendingMutationCount = (s) => s.outbox.filter((i) => i.status === "pending").length;
@@ -3316,6 +3395,7 @@ function emptyState(init) {
3316
3395
  registering: /* @__PURE__ */ new Set(),
3317
3396
  membershipReread: /* @__PURE__ */ new Map(),
3318
3397
  versions: /* @__PURE__ */ new Map(),
3398
+ views: /* @__PURE__ */ new Map(),
3319
3399
  outbox: [],
3320
3400
  pendingWrites: /* @__PURE__ */ new Map(),
3321
3401
  failedCount: 0,
@@ -3362,6 +3442,24 @@ function parseViewRow(row) {
3362
3442
  confirmed: row.confirmed === true
3363
3443
  };
3364
3444
  }
3445
+ const pairIds = (pairs) => Array.isArray(pairs) ? pairs.filter((p) => Array.isArray(p) && typeof p[0] === "string").map((p) => p[0]) : [];
3446
+ /** `SELECT * FROM _00_view` rows, keyed by view key; junk rows are skipped. */
3447
+ function parseViewIndex(rows) {
3448
+ if (!Array.isArray(rows)) return [];
3449
+ const out = [];
3450
+ for (const row of rows) {
3451
+ if (!row || typeof row !== "object" || !Array.isArray(row.ids)) continue;
3452
+ const id = row.id;
3453
+ const key = typeof id === "string" ? id.slice(id.indexOf(":") + 1) : String(id?.id ?? "");
3454
+ if (!key) continue;
3455
+ out.push({
3456
+ key,
3457
+ ids: [...new Set([...pairIds(row.ids), ...pairIds(row.children)])],
3458
+ updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0
3459
+ });
3460
+ }
3461
+ return out;
3462
+ }
3365
3463
  /**
3366
3464
  * "Resolved before": the server answered this query on this device at some
3367
3465
  * point. An unconfirmed empty row cannot be told apart from one written before
@@ -3634,11 +3732,19 @@ const selfHealDelayMs = (attempt) => Math.min(SELF_HEAL_MAX_MS, SELF_HEAL_BASE_M
3634
3732
  const VIEW_TABLE = "_00_view";
3635
3733
  const LEGACY_VIEW_TABLE = "_00_window";
3636
3734
  const viewRecordId = (viewKey) => new RecordId$1(VIEW_TABLE, viewKey);
3637
- const viewRow = (ids, confirmed, now) => ({
3735
+ /**
3736
+ * The durable `_00_view` row: the confirmed membership plus the subquery
3737
+ * children, which the GC must keep and a cold scan may paint as much as the
3738
+ * members.
3739
+ */
3740
+ const viewRow = (ids, children, confirmed, now) => ({
3638
3741
  ids,
3742
+ children,
3639
3743
  confirmed,
3640
3744
  updatedAt: now
3641
3745
  });
3746
+ /** Every `_00_view` row: the durable view index and the GC read it. */
3747
+ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
3642
3748
  /** Copy every legacy `_00_window` row into `_00_view` once. */
3643
3749
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
3644
3750
  const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
@@ -3747,7 +3853,7 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
3747
3853
  });
3748
3854
  const now = yield fx.now();
3749
3855
  try {
3750
- yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, true, now), "replace");
3856
+ yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, entry.subqueryRemoteArray, true, now), "replace");
3751
3857
  } catch (err) {
3752
3858
  yield fx.emit({
3753
3859
  type: "log",
@@ -3777,11 +3883,28 @@ function* recoverLostView(hash) {
3777
3883
  hash
3778
3884
  });
3779
3885
  }
3780
- /** Replace the subquery child set; bodies follow through the fetch plan. */
3886
+ /**
3887
+ * Replace the subquery child set; bodies follow through the fetch plan. A
3888
+ * query with a `_00_view` row (past `cold`) records the children there too,
3889
+ * so the GC keeps their bodies and a cold scan may paint them.
3890
+ */
3781
3891
  function* applySubqueryChildren(hash, children) {
3782
3892
  const entry = yield fx.state.read((s) => s.queries.get(hash));
3783
3893
  if (!entry || recordVersionArraysEqual(entry.subqueryRemoteArray, children)) return;
3784
3894
  yield fx.state.update(setSubqueryRemoteArray(hash, children));
3895
+ if (entry.lifecycle.phase !== "cold") try {
3896
+ yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), { children }, "merge");
3897
+ } catch (err) {
3898
+ yield fx.emit({
3899
+ type: "log",
3900
+ level: "debug",
3901
+ message: "view row write failed",
3902
+ data: {
3903
+ hash,
3904
+ err
3905
+ }
3906
+ });
3907
+ }
3785
3908
  yield fx.dispatch({ type: "FetchRows" });
3786
3909
  }
3787
3910
  const ok = (r) => r.ok;
@@ -4211,42 +4334,65 @@ function* ackPrune() {
4211
4334
  yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
4212
4335
  if (yield fx.state.read((s) => s.outbox.some((i) => i.status === "acked"))) yield fx.timer.set("ack-prune", ACK_GRACE_MS, { type: "AckPrune" });
4213
4336
  }
4337
+ /** Every `_00_view` row, parsed. Throws when the read fails. */
4338
+ function* readViews() {
4339
+ const res = yield fx.local.query(readViewRows());
4340
+ return parseViewIndex(Array.isArray(res) ? res[0] : void 0);
4341
+ }
4342
+ const indexOf = (rows) => new Map(rows.map((r) => [r.key, r.ids]));
4343
+ /**
4344
+ * Load the durable view index (boot, bucket switch). A failed read leaves it
4345
+ * empty, which fails closed: cold scans paint nothing until the server answers.
4346
+ */
4347
+ function* loadViews() {
4348
+ try {
4349
+ yield fx.state.update(reloadViews(indexOf(yield* readViews())));
4350
+ } catch (error) {
4351
+ yield fx.emit({
4352
+ type: "log",
4353
+ level: "warn",
4354
+ message: "view index read failed",
4355
+ data: { error }
4356
+ });
4357
+ }
4358
+ }
4359
+ const tableOf$1 = (id) => id.slice(0, id.indexOf(":"));
4214
4360
  /**
4215
- * Weekly orphan collection: bodies the store holds that no `_00_view` row
4216
- * names and no outbox item touches are invisible; delete them in chunks.
4361
+ * Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
4362
+ * `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
4363
+ * rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
4364
+ * nothing retains (see `retained`) from the store and the circuit, a chunk at
4365
+ * a time. Only a leader or solo tab deletes, and a leader relays the deletes
4366
+ * so followers drop the rows too; a follower only refreshes its index.
4217
4367
  */
4218
4368
  function* gcTick() {
4369
+ yield fx.state.wait((s) => s.primed);
4219
4370
  try {
4220
- const views = yield fx.local.query(`SELECT ids FROM ${VIEW_TABLE}`);
4221
- const rows = Array.isArray(views?.[0]) ? views[0] : [];
4222
- const keep = /* @__PURE__ */ new Set();
4223
- for (const row of rows) {
4224
- if (!Array.isArray(row.ids)) continue;
4225
- for (const pair of row.ids) keep.add(pair[0]);
4226
- }
4227
- const state = yield fx.state.read((s) => s);
4228
- for (const item of state.outbox) keep.add(item.recordId);
4229
- const orphans = [...state.versions.keys()].filter((id) => !keep.has(id) && !id.startsWith("_00_"));
4230
- const deletes = orphans.map((id) => {
4231
- const table = id.slice(0, id.indexOf(":"));
4232
- return fx.local.delete(table, id);
4233
- });
4234
- for (let i = 0; i < deletes.length; i += 200) {
4235
- const settled = yield fx.all(deletes.slice(i, i + 200));
4236
- const done = orphans.slice(i, i + 200).filter((_, j) => settled[j].ok);
4237
- yield fx.state.update(deleteVersions(done));
4238
- if (done.length > 0) yield fx.ssp.ingest(done.map((id) => ({
4239
- table: id.slice(0, id.indexOf(":")),
4240
- op: "DELETE",
4241
- id,
4242
- record: {}
4243
- })));
4244
- }
4371
+ const rows = yield* readViews();
4372
+ const [role, bucket, held] = yield fx.state.read((s) => [
4373
+ s.tabRole,
4374
+ s.bucketId,
4375
+ new Set([...s.queries.values()].map((e) => e.def.viewKey))
4376
+ ]);
4377
+ const now = yield fx.now();
4378
+ const expired = role === "follower" ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
4379
+ const retired = /* @__PURE__ */ new Set();
4380
+ for (let i = 0; i < expired.length; i += GC_CHUNK) {
4381
+ const chunk = expired.slice(i, i + GC_CHUNK);
4382
+ const settled = yield fx.all(chunk.map((r) => fx.local.delete(VIEW_TABLE, viewRecordId(r.key))));
4383
+ chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
4384
+ }
4385
+ yield fx.state.update(reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
4386
+ if (role === "follower") return;
4387
+ const removed = yield* collectOrphans(bucket, role === "leader");
4245
4388
  yield fx.emit({
4246
4389
  type: "log",
4247
4390
  level: "info",
4248
4391
  message: "orphan gc done",
4249
- data: { removed: orphans.length }
4392
+ data: {
4393
+ removed,
4394
+ retiredViews: retired.size
4395
+ }
4250
4396
  });
4251
4397
  } catch (error) {
4252
4398
  yield fx.emit({
@@ -4255,8 +4401,50 @@ function* gcTick() {
4255
4401
  message: "orphan gc failed",
4256
4402
  data: { error }
4257
4403
  });
4404
+ } finally {
4405
+ yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
4406
+ }
4407
+ }
4408
+ /**
4409
+ * Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
4410
+ * re-checked against fresh state (a membership committed meanwhile may name
4411
+ * an id again) and the sweep stops if the bucket moved under it.
4412
+ */
4413
+ function* collectOrphans(bucket, relay) {
4414
+ const candidates = yield fx.state.read((s) => {
4415
+ const keep = retained(s);
4416
+ return [...s.versions.keys()].filter((id) => !keep(id));
4417
+ });
4418
+ let removed = 0;
4419
+ for (let i = 0; i < candidates.length; i += GC_CHUNK) {
4420
+ const chunk = yield fx.state.read((s) => {
4421
+ if (!s.primed || s.bucketId !== bucket) return null;
4422
+ const keep = retained(s);
4423
+ return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
4424
+ });
4425
+ if (chunk === null) break;
4426
+ const settled = yield fx.all(chunk.map((id) => fx.local.delete(tableOf$1(id), id)));
4427
+ const done = chunk.filter((_, j) => settled[j].ok);
4428
+ if (done.length === 0) continue;
4429
+ const records = done.map((id) => ({
4430
+ table: tableOf$1(id),
4431
+ op: "DELETE",
4432
+ id,
4433
+ record: {}
4434
+ }));
4435
+ yield fx.state.update(deleteVersions(done));
4436
+ yield fx.ssp.ingest(records);
4437
+ if (relay) yield fx.emit({
4438
+ type: "tabs:broadcast",
4439
+ message: {
4440
+ type: "ingest",
4441
+ records
4442
+ }
4443
+ });
4444
+ removed += done.length;
4258
4445
  }
4259
- yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
4446
+ if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
4447
+ return removed;
4260
4448
  }
4261
4449
 
4262
4450
  //#endregion
@@ -5215,6 +5403,7 @@ function* boot(env, opts) {
5215
5403
  yield fx.state.update(setIdentity({ bucketId: bucket }));
5216
5404
  if (yield fx.service("local.usesSurqlSchema")) yield fx.service("migrator.provision");
5217
5405
  yield* migrateWindowToView();
5406
+ yield* loadViews();
5218
5407
  yield fx.dispatch({ type: "WarmBlobs" });
5219
5408
  yield fx.service("ssp.init");
5220
5409
  yield fx.service("ssp.setPermissions");
@@ -5234,7 +5423,7 @@ function* boot(env, opts) {
5234
5423
  yield fx.service("features.init");
5235
5424
  yield fx.service("releases.init");
5236
5425
  yield fx.dispatch({ type: "LifecycleTick" });
5237
- yield fx.dispatch({ type: "GcTick" });
5426
+ yield fx.timer.set("gc", GC_BOOT_DELAY_MS, { type: "GcTick" });
5238
5427
  yield fx.state.update(setIdentity({ localReady: true }));
5239
5428
  yield fx.dispatch({ type: "StartRemote" });
5240
5429
  }
@@ -5736,6 +5925,17 @@ function rowsFromResult(effect, result) {
5736
5925
  const first = Array.isArray(result) ? result[0] : void 0;
5737
5926
  return Array.isArray(first) ? first : [];
5738
5927
  }
5928
+ /**
5929
+ * A row's encoded record id: a string on the SQLite engine, a `RecordId` on
5930
+ * the SurrealDB one. `null` for a row that carries none (a projection
5931
+ * without `id`, a `SELECT VALUE`).
5932
+ */
5933
+ function rowKey(row) {
5934
+ const id = row !== null && typeof row === "object" ? row.id : void 0;
5935
+ if (typeof id === "string") return id;
5936
+ if (id !== null && typeof id === "object" && "table" in id && "id" in id) return encodeRecordId(id);
5937
+ return null;
5938
+ }
5739
5939
  /** Cheap structural equality for the "did the rows change" check. */
5740
5940
  function rowsEqual(a, b) {
5741
5941
  if (a === b) return true;
@@ -5757,6 +5957,16 @@ function resolveMembership(input) {
5757
5957
  return null;
5758
5958
  }
5759
5959
  /**
5960
+ * Whether a body may paint before the server has answered (`cold`). One no
5961
+ * durable view vouches for and no optimistic write touches is an orphan:
5962
+ * deleted upstream while no view watched it, or dropped from every view since.
5963
+ * The predicate scan would otherwise resurrect it until the server answers,
5964
+ * and indefinitely offline.
5965
+ */
5966
+ function paintsCold(id, vouched, overlay) {
5967
+ return id.startsWith("_00_") || vouched.has(id) || overlay.writes.has(id);
5968
+ }
5969
+ /**
5760
5970
  * render = (membership ∪ (writes ∩ localView)) − deletes
5761
5971
  *
5762
5972
  * `writes`/`deletes` are the outbox overlay (pending and acked items). The
@@ -5784,21 +5994,32 @@ function buildRenderIds(membership, localView, overlay, opts) {
5784
5994
 
5785
5995
  //#endregion
5786
5996
  //#region src/query/materialize.saga.ts
5997
+ const NONE = /* @__PURE__ */ new Set();
5787
5998
  /**
5788
5999
  * Render one query from state: membership (or a cold scan), the outbox
5789
6000
  * overlay, one local read, then notify subscribers if the rows changed. The
5790
6001
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
5791
- * query is dirty.
6002
+ * query is dirty. Before the server has answered (`cold`) only bodies some
6003
+ * durable view vouches for paint (see `paintsCold`).
5792
6004
  */
5793
6005
  function* materialize(hash) {
5794
- const [entry, ov] = yield fx.state.read((s) => [s.queries.get(hash), overlay(s)]);
6006
+ const [entry, ov, known] = yield fx.state.read((s) => {
6007
+ const e = s.queries.get(hash);
6008
+ return [
6009
+ e,
6010
+ overlay(s),
6011
+ e?.lifecycle.phase === "cold" ? vouched(s) : NONE
6012
+ ];
6013
+ });
5795
6014
  if (!entry) return;
5796
6015
  const t0 = yield fx.now();
5797
6016
  const isWindow = isWindowed(entry.def.surql);
6017
+ const paints = (id) => id === null || paintsCold(id, known, ov);
6018
+ const coldWindow = entry.lifecycle.phase === "cold" && isWindow;
5798
6019
  const membership = resolveMembership({
5799
6020
  phase: entry.lifecycle.phase,
5800
6021
  remoteArray: entry.remoteArray,
5801
- localArray: entry.localArray,
6022
+ localArray: coldWindow ? entry.localArray.filter(([id]) => paints(id)) : entry.localArray,
5802
6023
  isWindow
5803
6024
  });
5804
6025
  const ids = membership ? buildRenderIds(membership, entry.localArray, ov, {
@@ -5809,6 +6030,7 @@ function* materialize(hash) {
5809
6030
  let rows;
5810
6031
  try {
5811
6032
  rows = rowsFromResult(effect, yield effect);
6033
+ if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
5812
6034
  } catch (error) {
5813
6035
  yield fx.state.update(compose(recordError(hash), clearDirty(hash)));
5814
6036
  yield fx.emit({
@@ -6118,6 +6340,7 @@ function* tabMessage(env, raw) {
6118
6340
  });
6119
6341
  }
6120
6342
  yield fx.state.update(compose(setVersions(msg.records.filter((r) => r.op !== "DELETE").map((r) => [r.id, typeof r.record?._00_rv === "number" ? r.record._00_rv : 0])), deleteVersions(msg.records.filter((r) => r.op === "DELETE").map((r) => r.id)), ...[...new Set(msg.records.map((r) => r.table))].map((t) => markTableDirty(t))));
6343
+ if (msg.records.some((r) => r.op === "DELETE")) yield fx.dispatch({ type: "FetchRows" });
6121
6344
  return;
6122
6345
  case "membership-dirty":
6123
6346
  if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
@@ -6236,6 +6459,7 @@ function* bucketSwitch(env, target, release = null, opts = { clearBlobsOnSignOut
6236
6459
  data: { error }
6237
6460
  });
6238
6461
  }
6462
+ yield* loadViews();
6239
6463
  yield* rebindQueries();
6240
6464
  yield* loadOutbox(env);
6241
6465
  yield fx.dispatch({ type: "EnsureRegistered" });
@@ -13787,7 +14011,7 @@ function createTabsCoordinator(config, s, host) {
13787
14011
  return new TabsCoordinator({
13788
14012
  tabId: s.tabId,
13789
14013
  fingerprint: computeTabsFingerprint({
13790
- coreVersion: "0.0.1-canary.286",
14014
+ coreVersion: "0.0.1-canary.287",
13791
14015
  schemaHash: hash53(config.schemaSurql),
13792
14016
  endpoint: config.database.endpoint ?? "",
13793
14017
  namespace: config.database.namespace,
package/dist/live.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as registerVars, C as heartbeatRowGone, L as sha256Hex, R as queryHashInput, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as singleSnapshotSelect, k as registerSelect, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout } from "./worker-edges.js";
1
+ import { A as registerSelect, C as heartbeatRowGone, M as singleSnapshotSelect, R as sha256Hex, S as heartbeatBatch, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, h as listRefTableFor, j as registerVars, l as parseRecordIdString, n as rowOfEdge, p as DEFAULT_REF_MODE, s as encodeRecordId, t as hashOfEdge, u as withTimeout, z as queryHashInput } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims } from "./worker-impersonation.js";
3
3
  import { RecordId, Surreal } from "surrealdb";
4
4
 
package/dist/pure.d.ts CHANGED
@@ -181,7 +181,14 @@ type Vars = Record<string, unknown>;
181
181
  declare const VIEW_TABLE = "_00_view";
182
182
  declare const LEGACY_VIEW_TABLE = "_00_window";
183
183
  declare const viewRecordId: (viewKey: string) => RecordId<string>;
184
- declare const viewRow: (ids: RecordVersionArray, confirmed: boolean, now: number) => Record<string, unknown>;
184
+ /**
185
+ * The durable `_00_view` row: the confirmed membership plus the subquery
186
+ * children, which the GC must keep and a cold scan may paint as much as the
187
+ * members.
188
+ */
189
+ declare const viewRow: (ids: RecordVersionArray, children: RecordVersionArray, confirmed: boolean, now: number) => Record<string, unknown>;
190
+ /** Every `_00_view` row: the durable view index and the GC read it. */
191
+ declare const readViewRows: () => string;
185
192
  /** Copy every legacy `_00_window` row into `_00_view` once. */
186
193
  declare const readLegacyViewRows: () => string;
187
194
  declare const countViewRows: () => string;
@@ -319,4 +326,4 @@ declare function sha256Hex(input: string): Promise<string>;
319
326
  */
320
327
  declare function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string>;
321
328
  //#endregion
322
- export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
329
+ export { ANON_USER_ID, type Answer, BridgeSignOutMessage, BridgeTokenMessage, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, type ListRefSnapshot, PUSH_PAYLOAD_VERSION, PushNotification, PushNotificationAction, PushOp, PushPayload, PushPayloadKind, PushUrgency, type QueryKeyInput, type QueryMetaRow, type RefMode, RegisterPayload, type SealOptions, type SealedQuery, type SurqlHelper, type TokenClaims, type TxQuery, VIEW_TABLE, WebPushClientMessage, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
package/dist/pure.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as registerVars, C as heartbeatRowGone, D as queryRowCountSelect, E as queryRowCountBatchSelect, F as viewRow, I as surql, L as sha256Hex, M as subqueryListRefSelect, N as upsertBodiesTx, O as readLegacyViewRows, P as viewRecordId, R as queryHashInput, S as heartbeatBatch, T as listRefSelect, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as singleSnapshotSelect, k as registerSelect, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as listRefBatchSelect, x as countViewRows, y as batchSnapshotSelect, z as viewKeyInput } from "./worker-edges.js";
1
+ import { A as registerSelect, B as viewKeyInput, C as heartbeatRowGone, D as queryRowCountSelect, E as queryRowCountBatchSelect, F as viewRecordId, I as viewRow, L as surql, M as singleSnapshotSelect, N as subqueryListRefSelect, O as readLegacyViewRows, P as upsertBodiesTx, R as sha256Hex, S as heartbeatBatch, T as listRefSelect, _ as LEGACY_VIEW_TABLE, a as metaFromRow, b as bodySelect, c as parseDuration, d as parseQueryParams, f as ANON_USER_ID, g as sanitizeUserId, h as listRefTableFor, i as dedupeRecordVersions, j as registerVars, k as readViewRows, l as parseRecordIdString, m as bucketIdForUser, n as rowOfEdge, o as snapshotFromSingle, p as DEFAULT_REF_MODE, r as FAILED, s as encodeRecordId, t as hashOfEdge, v as VIEW_TABLE, w as listRefBatchSelect, x as countViewRows, y as batchSnapshotSelect, z as queryHashInput } from "./worker-edges.js";
2
2
  import { n as decodeTokenClaims, t as IMPERSONATION_ACCESS } from "./worker-impersonation.js";
3
3
  import { a as sameBytes, i as isPushPayload, n as base64UrlToBytes, r as bytesToBase64Url, t as PUSH_PAYLOAD_VERSION } from "./worker-types.js";
4
4
 
@@ -21,4 +21,4 @@ function queryKey(input, sessionId) {
21
21
  }
22
22
 
23
23
  //#endregion
24
- export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
24
+ export { ANON_USER_ID, DEFAULT_REF_MODE, FAILED, IMPERSONATION_ACCESS, LEGACY_VIEW_TABLE, PUSH_PAYLOAD_VERSION, VIEW_TABLE, base64UrlToBytes, batchSnapshotSelect, bodySelect, bucketIdForUser, bytesToBase64Url, countViewRows, decodeTokenClaims, dedupeRecordVersions, encodeRecordId, hashOfEdge, heartbeatBatch, heartbeatRowGone, isPushPayload, listRefBatchSelect, listRefSelect, listRefTableFor, metaFromRow, parseDuration, parseQueryParams, parseRecordIdString, queryHashInput, queryKey, queryRowCountBatchSelect, queryRowCountSelect, readLegacyViewRows, readViewRows, registerSelect, registerVars, rowOfEdge, sameBytes, sanitizeUserId, sha256Hex, singleSnapshotSelect, snapshotFromSingle, subqueryListRefSelect, surql, upsertBodiesTx, viewKeyInput, viewRecordId, viewRow };
@@ -103,11 +103,19 @@ const surql = {
103
103
  const VIEW_TABLE = "_00_view";
104
104
  const LEGACY_VIEW_TABLE = "_00_window";
105
105
  const viewRecordId = (viewKey) => new RecordId(VIEW_TABLE, viewKey);
106
- const viewRow = (ids, confirmed, now) => ({
106
+ /**
107
+ * The durable `_00_view` row: the confirmed membership plus the subquery
108
+ * children, which the GC must keep and a cold scan may paint as much as the
109
+ * members.
110
+ */
111
+ const viewRow = (ids, children, confirmed, now) => ({
107
112
  ids,
113
+ children,
108
114
  confirmed,
109
115
  updatedAt: now
110
116
  });
117
+ /** Every `_00_view` row: the durable view index and the GC read it. */
118
+ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
111
119
  /** Copy every legacy `_00_window` row into `_00_view` once. */
112
120
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
113
121
  const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
@@ -412,4 +420,4 @@ function rowOfEdge(value) {
412
420
  }
413
421
 
414
422
  //#endregion
415
- export { registerVars as A, heartbeatRowGone as C, queryRowCountSelect as D, queryRowCountBatchSelect as E, viewRow as F, surql as I, sha256Hex as L, subqueryListRefSelect as M, upsertBodiesTx as N, readLegacyViewRows as O, viewRecordId as P, queryHashInput as R, heartbeatBatch as S, listRefSelect as T, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, singleSnapshotSelect as j, registerSelect as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, listRefBatchSelect as w, countViewRows as x, batchSnapshotSelect as y, viewKeyInput as z };
423
+ export { registerSelect as A, viewKeyInput as B, heartbeatRowGone as C, queryRowCountSelect as D, queryRowCountBatchSelect as E, viewRecordId as F, viewRow as I, surql as L, singleSnapshotSelect as M, subqueryListRefSelect as N, readLegacyViewRows as O, upsertBodiesTx as P, sha256Hex as R, heartbeatBatch as S, listRefSelect as T, LEGACY_VIEW_TABLE as _, metaFromRow as a, bodySelect as b, parseDuration as c, parseQueryParams as d, ANON_USER_ID as f, sanitizeUserId as g, listRefTableFor as h, dedupeRecordVersions as i, registerVars as j, readViewRows as k, parseRecordIdString as l, bucketIdForUser as m, rowOfEdge as n, snapshotFromSingle as o, DEFAULT_REF_MODE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, withTimeout as u, VIEW_TABLE as v, listRefBatchSelect as w, countViewRows as x, batchSnapshotSelect as y, queryHashInput as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.286",
3
+ "version": "0.0.1-canary.287",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -75,8 +75,8 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@spooky-sync/query-builder": "0.0.1-canary.286",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.286",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.287",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.287",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -19,7 +19,11 @@ describe('boot', () => {
19
19
  'auth.access': () => 'account',
20
20
  });
21
21
  const out = await runPure(boot(env, { sharedTabs: false }), {
22
- handlers: { service: svc.handler, 'local.query': (e: any) => (e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : []) },
22
+ handlers: {
23
+ service: svc.handler,
24
+ 'local.query': (e: any) =>
25
+ e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : e.sql === 'SELECT * FROM _00_view' ? [[{ id: '_00_view:k', ids: [['t:1', 1]] }]] : [],
26
+ },
23
27
  });
24
28
  expect(svc.names()).toEqual([
25
29
  'hint.read',
@@ -39,7 +43,10 @@ describe('boot', () => {
39
43
  ]);
40
44
  expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
41
45
  expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
42
- expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'GcTick', 'StartRemote']);
46
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'StartRemote']);
47
+ // The view index is loaded before anything can paint; the first GC waits out the boot.
48
+ expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
49
+ expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
43
50
  expect(svc.names()).not.toContain('remote.connect');
44
51
  });
45
52
  it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {