@spooky-sync/core 0.0.1-canary.283 → 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.
Files changed (45) hide show
  1. package/AGENTS.md +1 -0
  2. package/dist/index.d.ts +6 -0
  3. package/dist/index.js +306 -53
  4. package/dist/live.js +1 -1
  5. package/dist/pure.d.ts +9 -2
  6. package/dist/pure.js +2 -2
  7. package/dist/sqlite-open.js +28 -1
  8. package/dist/sqlite-worker.js +1 -1
  9. package/dist/worker-edges.js +10 -2
  10. package/package.json +3 -3
  11. package/skills/sp00ky-core/SKILL.md +9 -6
  12. package/src/boot/boot.saga.test.ts +9 -2
  13. package/src/boot/boot.saga.ts +4 -1
  14. package/src/boot/bucket-switch.saga.ts +2 -0
  15. package/src/client/runtime.test.ts +4 -1
  16. package/src/client/sp00ky-client.test.ts +8 -1
  17. package/src/kernel/constants.ts +15 -2
  18. package/src/query/fetch.saga.test.ts +44 -1
  19. package/src/query/fetch.saga.ts +7 -2
  20. package/src/query/lifecycle.saga.test.ts +169 -17
  21. package/src/query/lifecycle.saga.ts +86 -28
  22. package/src/query/materialize.saga.test.ts +36 -3
  23. package/src/query/materialize.saga.ts +15 -6
  24. package/src/query/materialize.test.ts +9 -1
  25. package/src/query/materialize.ts +14 -1
  26. package/src/query/membership.saga.test.ts +30 -2
  27. package/src/query/membership.saga.ts +18 -2
  28. package/src/query/membership.ts +30 -0
  29. package/src/query/render-set.test.ts +12 -1
  30. package/src/query/render-set.ts +11 -0
  31. package/src/query/sql.test.ts +2 -1
  32. package/src/query/sql.ts +15 -1
  33. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  34. package/src/services/database/sqlite-cache-engine.ts +9 -5
  35. package/src/services/database/sqlite-plan-sql.test.ts +35 -0
  36. package/src/services/database/sqlite-plan-sql.ts +26 -0
  37. package/src/state/client-state.ts +7 -0
  38. package/src/state/reducers.test.ts +19 -0
  39. package/src/state/reducers.ts +41 -3
  40. package/src/state/selectors.test.ts +18 -0
  41. package/src/state/selectors.ts +32 -0
  42. package/src/sync/tabs.saga.test.ts +4 -0
  43. package/src/sync/tabs.saga.ts +3 -0
  44. package/src/utils/parser.test.ts +37 -1
  45. package/src/utils/parser.ts +32 -0
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
@@ -1,4 +1,4 @@
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";
1
+ import { a as renderOrderSql, c as serializeMergeRow, d as stableKey, i as projectedDataSql, l as serializeRow, o as renderWhereSql, r as project, s as reviveRow, t as PROMOTION_OPEN_OPTIONS, u as resolveRelations } from "./sqlite-open.js";
2
2
  import { QueryBuilder, RecordId, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
3
3
  import { DateTime, Duration, RecordId as RecordId$1, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
4
4
  import pino from "pino";
@@ -448,6 +448,34 @@ function cleanRecord(tableSchema, record) {
448
448
  return cleaned;
449
449
  }
450
450
  /**
451
+ * The OPTIONAL schema columns a full server row does not carry, each mapped to
452
+ * `undefined`, for spreading into a body that is landed with MERGE.
453
+ *
454
+ * The server omits an optional field that is unset (`UPDATE t UNSET club`
455
+ * leaves no `club` key in `SELECT *` or the SSP body), and MERGE keeps every
456
+ * key the patch does not mention, so without this the local store held on to
457
+ * a cleared value forever. `undefined` is the one representation both engines
458
+ * read as "remove": the SurrealDB SDK encodes it as NONE, which MERGE removes
459
+ * (NULL would be a type error on an `option<record<...>>` field), and the
460
+ * SQLite engine turns it into the `null` member that `json_patch` removes (see
461
+ * `serializeMergeRow`).
462
+ *
463
+ * Required columns are left alone. A SCHEMAFULL server cannot hold NONE in
464
+ * one, so its absence means the row was not readable in full (a field
465
+ * permission), not that it was cleared, and NONE in a typed local store is a
466
+ * coercion error that would fail the whole chunk's transaction. `id` and the
467
+ * local-only `_00_*` bookkeeping are never touched.
468
+ */
469
+ function absentOptionalColumns(tableSchema, record) {
470
+ const cleared = {};
471
+ if (!tableSchema) return cleared;
472
+ for (const [key, column] of Object.entries(tableSchema)) {
473
+ if (key === "id" || key.startsWith("_00_") || column?.optional !== true) continue;
474
+ if (record[key] === void 0) cleared[key] = void 0;
475
+ }
476
+ return cleared;
477
+ }
478
+ /**
451
479
  * Parse a RECORD's fields against the table schema. Anything the schema does not
452
480
  * know is dropped, which is what keeps a stray field out of a write.
453
481
  */
@@ -1724,8 +1752,8 @@ function selfAllowlistedVariant(flag, userId) {
1724
1752
 
1725
1753
  //#endregion
1726
1754
  //#region src/modules/devtools/index.ts
1727
- const CORE_VERSION = "0.0.1-canary.283";
1728
- const WASM_VERSION = "0.0.1-canary.283";
1755
+ const CORE_VERSION = "0.0.1-canary.287";
1756
+ const WASM_VERSION = "0.0.1-canary.287";
1729
1757
  const SURREAL_VERSION = "3.0.3";
1730
1758
  var DevToolsService = class DevToolsService {
1731
1759
  eventsHistory = [];
@@ -2410,8 +2438,21 @@ const AUTH_READY_RETRY_MS = 500;
2410
2438
  const AUTH_READY_MAX_ATTEMPTS = 10;
2411
2439
  /** Heartbeat at this fraction of the shortest ttl in state. */
2412
2440
  const TTL_HEARTBEAT_FRACTION = .5;
2413
- /** Orphan body garbage collection cadence. */
2414
- 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;
2415
2456
  /** Rolling telemetry sample window per query. */
2416
2457
  const TELEMETRY_SAMPLE_WINDOW = 100;
2417
2458
  function backoffMs(attempt, base = RETRY_BASE_MS, max = RETRY_MAX_MS) {
@@ -2484,6 +2525,32 @@ const setServerState = (hash, serverState) => (s) => withEntry(s, hash, (e) => e
2484
2525
  * set names: membership has caught up with the write, the overlay's job is
2485
2526
  * done (invariant I7).
2486
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
+ };
2487
2554
  const commitMembership = (hash, remoteArray, present) => (s) => {
2488
2555
  const withArray = withEntry(s, hash, (e) => ({
2489
2556
  ...e,
@@ -2494,10 +2561,11 @@ const commitMembership = (hash, remoteArray, present) => (s) => {
2494
2561
  })
2495
2562
  }));
2496
2563
  if (withArray === s) return s;
2564
+ const entry = withArray.queries.get(hash);
2497
2565
  const named = new Set(remoteArray.map(([id]) => id));
2498
2566
  const outbox = withArray.outbox.filter((item) => !(item.status === "acked" && named.has(item.recordId)));
2499
2567
  return {
2500
- ...withArray,
2568
+ ...withView(withArray, entry.def.viewKey, vouchedIds(remoteArray, entry.subqueryRemoteArray)),
2501
2569
  outbox,
2502
2570
  dirty: addAll(withArray.dirty, [hash])
2503
2571
  };
@@ -2512,10 +2580,19 @@ const setLocalArray = (hash, localArray) => (s) => {
2512
2580
  dirty: addAll(next.dirty, [hash])
2513
2581
  };
2514
2582
  };
2515
- const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => withEntry(s, hash, (e) => ({
2516
- ...e,
2517
- subqueryRemoteArray
2518
- }));
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
+ };
2519
2596
  const setRecords = (hash, records, changed, materializeMs) => (s) => {
2520
2597
  const next = withEntry(s, hash, (e) => {
2521
2598
  const prev = e.telemetry.phaseSamples.localFetch ?? [];
@@ -2816,6 +2893,7 @@ const rebindQuery = (hash, next) => (s) => {
2816
2893
  const clearBucketState = () => (s) => ({
2817
2894
  ...s,
2818
2895
  versions: /* @__PURE__ */ new Map(),
2896
+ views: /* @__PURE__ */ new Map(),
2819
2897
  outbox: [],
2820
2898
  pendingWrites: /* @__PURE__ */ new Map(),
2821
2899
  dirty: /* @__PURE__ */ new Set(),
@@ -2875,6 +2953,35 @@ function overlay(s) {
2875
2953
  deletes
2876
2954
  };
2877
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
+ }
2878
2985
  const pendingDeleteIds = (s) => new Set(s.outbox.filter((i) => i.type === "delete").map((i) => i.recordId));
2879
2986
  const hasAckedWrites = (s) => s.outbox.some((i) => i.status === "acked");
2880
2987
  const pendingMutationCount = (s) => s.outbox.filter((i) => i.status === "pending").length;
@@ -3288,6 +3395,7 @@ function emptyState(init) {
3288
3395
  registering: /* @__PURE__ */ new Set(),
3289
3396
  membershipReread: /* @__PURE__ */ new Map(),
3290
3397
  versions: /* @__PURE__ */ new Map(),
3398
+ views: /* @__PURE__ */ new Map(),
3291
3399
  outbox: [],
3292
3400
  pendingWrites: /* @__PURE__ */ new Map(),
3293
3401
  failedCount: 0,
@@ -3334,6 +3442,24 @@ function parseViewRow(row) {
3334
3442
  confirmed: row.confirmed === true
3335
3443
  };
3336
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
+ }
3337
3463
  /**
3338
3464
  * "Resolved before": the server answered this query on this device at some
3339
3465
  * point. An unconfirmed empty row cannot be told apart from one written before
@@ -3606,11 +3732,19 @@ const selfHealDelayMs = (attempt) => Math.min(SELF_HEAL_MAX_MS, SELF_HEAL_BASE_M
3606
3732
  const VIEW_TABLE = "_00_view";
3607
3733
  const LEGACY_VIEW_TABLE = "_00_window";
3608
3734
  const viewRecordId = (viewKey) => new RecordId$1(VIEW_TABLE, viewKey);
3609
- 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) => ({
3610
3741
  ids,
3742
+ children,
3611
3743
  confirmed,
3612
3744
  updatedAt: now
3613
3745
  });
3746
+ /** Every `_00_view` row: the durable view index and the GC read it. */
3747
+ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
3614
3748
  /** Copy every legacy `_00_window` row into `_00_view` once. */
3615
3749
  const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
3616
3750
  const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
@@ -3719,7 +3853,7 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
3719
3853
  });
3720
3854
  const now = yield fx.now();
3721
3855
  try {
3722
- 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");
3723
3857
  } catch (err) {
3724
3858
  yield fx.emit({
3725
3859
  type: "log",
@@ -3749,11 +3883,28 @@ function* recoverLostView(hash) {
3749
3883
  hash
3750
3884
  });
3751
3885
  }
3752
- /** 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
+ */
3753
3891
  function* applySubqueryChildren(hash, children) {
3754
3892
  const entry = yield fx.state.read((s) => s.queries.get(hash));
3755
3893
  if (!entry || recordVersionArraysEqual(entry.subqueryRemoteArray, children)) return;
3756
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
+ }
3757
3908
  yield fx.dispatch({ type: "FetchRows" });
3758
3909
  }
3759
3910
  const ok = (r) => r.ok;
@@ -4183,42 +4334,65 @@ function* ackPrune() {
4183
4334
  yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
4184
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" });
4185
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(":"));
4186
4360
  /**
4187
- * Weekly orphan collection: bodies the store holds that no `_00_view` row
4188
- * 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.
4189
4367
  */
4190
4368
  function* gcTick() {
4369
+ yield fx.state.wait((s) => s.primed);
4191
4370
  try {
4192
- const views = yield fx.local.query(`SELECT ids FROM ${VIEW_TABLE}`);
4193
- const rows = Array.isArray(views?.[0]) ? views[0] : [];
4194
- const keep = /* @__PURE__ */ new Set();
4195
- for (const row of rows) {
4196
- if (!Array.isArray(row.ids)) continue;
4197
- for (const pair of row.ids) keep.add(pair[0]);
4198
- }
4199
- const state = yield fx.state.read((s) => s);
4200
- for (const item of state.outbox) keep.add(item.recordId);
4201
- const orphans = [...state.versions.keys()].filter((id) => !keep.has(id) && !id.startsWith("_00_"));
4202
- const deletes = orphans.map((id) => {
4203
- const table = id.slice(0, id.indexOf(":"));
4204
- return fx.local.delete(table, id);
4205
- });
4206
- for (let i = 0; i < deletes.length; i += 200) {
4207
- const settled = yield fx.all(deletes.slice(i, i + 200));
4208
- const done = orphans.slice(i, i + 200).filter((_, j) => settled[j].ok);
4209
- yield fx.state.update(deleteVersions(done));
4210
- if (done.length > 0) yield fx.ssp.ingest(done.map((id) => ({
4211
- table: id.slice(0, id.indexOf(":")),
4212
- op: "DELETE",
4213
- id,
4214
- record: {}
4215
- })));
4216
- }
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");
4217
4388
  yield fx.emit({
4218
4389
  type: "log",
4219
4390
  level: "info",
4220
4391
  message: "orphan gc done",
4221
- data: { removed: orphans.length }
4392
+ data: {
4393
+ removed,
4394
+ retiredViews: retired.size
4395
+ }
4222
4396
  });
4223
4397
  } catch (error) {
4224
4398
  yield fx.emit({
@@ -4227,8 +4401,50 @@ function* gcTick() {
4227
4401
  message: "orphan gc failed",
4228
4402
  data: { error }
4229
4403
  });
4404
+ } finally {
4405
+ yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
4230
4406
  }
4231
- yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
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;
4445
+ }
4446
+ if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
4447
+ return removed;
4232
4448
  }
4233
4449
 
4234
4450
  //#endregion
@@ -5187,6 +5403,7 @@ function* boot(env, opts) {
5187
5403
  yield fx.state.update(setIdentity({ bucketId: bucket }));
5188
5404
  if (yield fx.service("local.usesSurqlSchema")) yield fx.service("migrator.provision");
5189
5405
  yield* migrateWindowToView();
5406
+ yield* loadViews();
5190
5407
  yield fx.dispatch({ type: "WarmBlobs" });
5191
5408
  yield fx.service("ssp.init");
5192
5409
  yield fx.service("ssp.setPermissions");
@@ -5206,7 +5423,7 @@ function* boot(env, opts) {
5206
5423
  yield fx.service("features.init");
5207
5424
  yield fx.service("releases.init");
5208
5425
  yield fx.dispatch({ type: "LifecycleTick" });
5209
- yield fx.dispatch({ type: "GcTick" });
5426
+ yield fx.timer.set("gc", GC_BOOT_DELAY_MS, { type: "GcTick" });
5210
5427
  yield fx.state.update(setIdentity({ localReady: true }));
5211
5428
  yield fx.dispatch({ type: "StartRemote" });
5212
5429
  }
@@ -5485,6 +5702,7 @@ function* landChunk(env, requested, rows, versions, state, epoch) {
5485
5702
  bodies.push({
5486
5703
  id: rid,
5487
5704
  content: {
5705
+ ...absentOptionalColumns(columns, row),
5488
5706
  ...content,
5489
5707
  _00_rv: version
5490
5708
  }
@@ -5707,6 +5925,17 @@ function rowsFromResult(effect, result) {
5707
5925
  const first = Array.isArray(result) ? result[0] : void 0;
5708
5926
  return Array.isArray(first) ? first : [];
5709
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
+ }
5710
5939
  /** Cheap structural equality for the "did the rows change" check. */
5711
5940
  function rowsEqual(a, b) {
5712
5941
  if (a === b) return true;
@@ -5728,6 +5957,16 @@ function resolveMembership(input) {
5728
5957
  return null;
5729
5958
  }
5730
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
+ /**
5731
5970
  * render = (membership ∪ (writes ∩ localView)) − deletes
5732
5971
  *
5733
5972
  * `writes`/`deletes` are the outbox overlay (pending and acked items). The
@@ -5755,21 +5994,32 @@ function buildRenderIds(membership, localView, overlay, opts) {
5755
5994
 
5756
5995
  //#endregion
5757
5996
  //#region src/query/materialize.saga.ts
5997
+ const NONE = /* @__PURE__ */ new Set();
5758
5998
  /**
5759
5999
  * Render one query from state: membership (or a cold scan), the outbox
5760
6000
  * overlay, one local read, then notify subscribers if the rows changed. The
5761
6001
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
5762
- * query is dirty.
6002
+ * query is dirty. Before the server has answered (`cold`) only bodies some
6003
+ * durable view vouches for paint (see `paintsCold`).
5763
6004
  */
5764
6005
  function* materialize(hash) {
5765
- 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
+ });
5766
6014
  if (!entry) return;
5767
6015
  const t0 = yield fx.now();
5768
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;
5769
6019
  const membership = resolveMembership({
5770
6020
  phase: entry.lifecycle.phase,
5771
6021
  remoteArray: entry.remoteArray,
5772
- localArray: entry.localArray,
6022
+ localArray: coldWindow ? entry.localArray.filter(([id]) => paints(id)) : entry.localArray,
5773
6023
  isWindow
5774
6024
  });
5775
6025
  const ids = membership ? buildRenderIds(membership, entry.localArray, ov, {
@@ -5780,6 +6030,7 @@ function* materialize(hash) {
5780
6030
  let rows;
5781
6031
  try {
5782
6032
  rows = rowsFromResult(effect, yield effect);
6033
+ if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
5783
6034
  } catch (error) {
5784
6035
  yield fx.state.update(compose(recordError(hash), clearDirty(hash)));
5785
6036
  yield fx.emit({
@@ -6089,6 +6340,7 @@ function* tabMessage(env, raw) {
6089
6340
  });
6090
6341
  }
6091
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" });
6092
6344
  return;
6093
6345
  case "membership-dirty":
6094
6346
  if (Array.isArray(msg.hashes)) yield* liveChange(env, msg.hashes);
@@ -6207,6 +6459,7 @@ function* bucketSwitch(env, target, release = null, opts = { clearBlobsOnSignOut
6207
6459
  data: { error }
6208
6460
  });
6209
6461
  }
6462
+ yield* loadViews();
6210
6463
  yield* rebindQueries();
6211
6464
  yield* loadOutbox(env);
6212
6465
  yield fx.dispatch({ type: "EnsureRegistered" });
@@ -9310,7 +9563,7 @@ var SqliteCacheEngine = class {
9310
9563
  await this.ensureTable(table);
9311
9564
  const key = stableKey(id);
9312
9565
  if (mode === "merge") {
9313
- const full = serializeRow({
9566
+ const { insert, patch } = serializeMergeRow({
9314
9567
  ...data,
9315
9568
  id: key
9316
9569
  });
@@ -9318,8 +9571,8 @@ var SqliteCacheEngine = class {
9318
9571
  sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
9319
9572
  bind: [
9320
9573
  key,
9321
- full,
9322
- full
9574
+ insert,
9575
+ patch
9323
9576
  ]
9324
9577
  });
9325
9578
  return;
@@ -9496,7 +9749,7 @@ var SqliteCacheEngine = class {
9496
9749
  const key = stableKey(op.id);
9497
9750
  ensure(t);
9498
9751
  if (op.mode === "merge") {
9499
- const full = serializeRow({
9752
+ const { insert, patch } = serializeMergeRow({
9500
9753
  ...op.data,
9501
9754
  id: key
9502
9755
  });
@@ -9504,8 +9757,8 @@ var SqliteCacheEngine = class {
9504
9757
  sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
9505
9758
  bind: [
9506
9759
  key,
9507
- full,
9508
- full
9760
+ insert,
9761
+ patch
9509
9762
  ]
9510
9763
  });
9511
9764
  } else stmts.push({
@@ -13758,7 +14011,7 @@ function createTabsCoordinator(config, s, host) {
13758
14011
  return new TabsCoordinator({
13759
14012
  tabId: s.tabId,
13760
14013
  fingerprint: computeTabsFingerprint({
13761
- coreVersion: "0.0.1-canary.283",
14014
+ coreVersion: "0.0.1-canary.287",
13762
14015
  schemaHash: hash53(config.schemaSurql),
13763
14016
  endpoint: config.database.endpoint ?? "",
13764
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 };