@spooky-sync/core 0.0.1-canary.288 → 0.0.1-canary.289

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
@@ -49,7 +49,7 @@ re-materialized from state, never patched in place.
49
49
  - **Use UI hooks, not the client directly.** In a Solid app, call `useDb()` from `@spooky-sync/client-solid`. Touch `Sp00kyClient` only inside `provider.client` for advanced flows.
50
50
  - **Mutations are optimistic.** `db.create` / `db.update` / `db.delete` return immediately; the queue drains in the background. Inspect progress via `pendingMutationCount`.
51
51
  - **Record IDs are full strings.** `db.create('thread:abc123', {...})` — *not* `db.create('thread', { id: 'abc123' })`. The first arg is `'<table>:<id>'`. Generate IDs with `Uuid` from `surrealdb` (re-exported by `client-solid`).
52
- - **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key } }`) coalesces rapid updates — use it for CRDT text/title fields.
52
+ - **`db.update` takes `(table, id, payload, options?)`**. The `debounced` option (`{ debounced: true }` or `{ debounced: { delay, key, flushOnHide } }`) coalesces rapid updates — use it for CRDT text/title fields. With `flushOnHide` (default `true`) the merged patch is also kept in the local `_00_pending_writes` table and pushed as soon as the page is hidden, so a reload or closed tab inside the delay does not lose it (the next boot moves a leftover patch into the outbox). `flushOnHide: false` keeps the patch in memory only.
53
53
  - **`db.remoteQuery(sql, vars?)` is the one-shot remote read.** It runs one SurrealQL statement through the client's remote path (connect gate, per-statement timeout, concurrency limit) and returns the SurrealDB result array; results are not synced into the local cache. Use it for counts and other reads the query builder can't express.
54
54
  - **`db.useRemote(fn)` hands out the bare SDK client.** Same cache bypass, but it skips the connect gate (a call during a reconnect fails with "Specify a namespace to use"). Reserve it for SDK features `remoteQuery` cannot reach (transactions, `live`).
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.
package/dist/index.d.ts CHANGED
@@ -207,6 +207,8 @@ declare class LocalMigrator {
207
207
  private logger;
208
208
  constructor(localDb: LocalStore, logger: Logger$1);
209
209
  provision(schemaSurql: string): Promise<void>;
210
+ private readKeptRows;
211
+ private restoreKeptRows;
210
212
  private isSchemaUpToDate;
211
213
  private recreateDatabase;
212
214
  private splitStatements;
@@ -892,6 +894,10 @@ type RuntimeEvent = {
892
894
  } | {
893
895
  type: 'FlushWrite';
894
896
  key: string;
897
+ } | {
898
+ type: 'FlushPendingWrites';
899
+ } | {
900
+ type: 'AdoptPendingWrites';
895
901
  } | {
896
902
  type: 'PollTick';
897
903
  } | {
@@ -1278,6 +1284,8 @@ interface PendingWrite {
1278
1284
  readonly data: Readonly<Record<string, unknown>>;
1279
1285
  readonly before: Readonly<Record<string, unknown>> | null;
1280
1286
  readonly firstAt: number;
1287
+ /** Its durable copy in `_00_pending_writes`; null when `flushOnHide` is off. */
1288
+ readonly mirrorId: string | null;
1281
1289
  }
1282
1290
  interface SyncSlice {
1283
1291
  readonly health: SyncHealth;
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.288";
1756
- const WASM_VERSION = "0.0.1-canary.288";
1755
+ const CORE_VERSION = "0.0.1-canary.289";
1756
+ const WASM_VERSION = "0.0.1-canary.289";
1757
1757
  const SURREAL_VERSION = "3.0.3";
1758
1758
  var DevToolsService = class DevToolsService {
1759
1759
  eventsHistory = [];
@@ -2439,6 +2439,12 @@ 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
2441
  /**
2442
+ * How long past its due flush another tab's `_00_pending_writes` row may sit
2443
+ * before it counts as abandoned (its tab died before the flush) and is moved
2444
+ * into the outbox here.
2445
+ */
2446
+ const PENDING_WRITE_ORPHAN_MS = 3e3;
2447
+ /**
2442
2448
  * Orphan body garbage collection: the first sweep waits this long after boot
2443
2449
  * (off the first paint and the first sync round), then repeats on the interval.
2444
2450
  */
@@ -2840,7 +2846,8 @@ const mergePendingWrite = (write) => (s) => {
2840
2846
  data: {
2841
2847
  ...prev.data,
2842
2848
  ...write.data
2843
- }
2849
+ },
2850
+ mirrorId: prev.mirrorId ?? write.mirrorId
2844
2851
  } : write);
2845
2852
  return {
2846
2853
  ...s,
@@ -4451,6 +4458,8 @@ function* collectOrphans(bucket, relay) {
4451
4458
  //#region src/mutation/rows.ts
4452
4459
  const PENDING_TABLE = "_00_pending_mutations";
4453
4460
  const FAILED_TABLE = "_00_failed_mutations";
4461
+ /** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
4462
+ const PENDING_WRITES_TABLE = "_00_pending_writes";
4454
4463
  const PENDING_ROW_VERSION = 2;
4455
4464
  /**
4456
4465
  * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
@@ -4527,7 +4536,25 @@ const toOutboxItem = (row) => ({
4527
4536
  ackedAt: null,
4528
4537
  attempts: 0
4529
4538
  });
4539
+ /** The `_00_pending_writes` id for a minted mutation id (same suffix). */
4540
+ const pendingWriteId = (mutationId) => `${PENDING_WRITES_TABLE}:${mutationId.includes(":") ? mutationId.slice(mutationId.indexOf(":") + 1) : mutationId}`;
4541
+ function parsePendingWriteRow(row) {
4542
+ if (!row || typeof row !== "object") return null;
4543
+ const r = row;
4544
+ if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
4545
+ if (!r.data || typeof r.data !== "object") return null;
4546
+ const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
4547
+ return {
4548
+ id: storedIdString(r.id),
4549
+ recordId,
4550
+ tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
4551
+ data: r.data,
4552
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
4553
+ flushBy: typeof r.flushBy === "number" ? r.flushBy : 0
4554
+ };
4555
+ }
4530
4556
  const loadPendingRows = () => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
4557
+ const loadPendingWriteRows = () => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
4531
4558
  const loadFailedRows = () => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
4532
4559
  const pendingInsert = (type, withData, withBefore) => {
4533
4560
  const fields = [
@@ -4596,34 +4623,50 @@ function planDeleteTx(input) {
4596
4623
  }
4597
4624
  };
4598
4625
  }
4599
- /** Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later. */
4626
+ /**
4627
+ * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
4628
+ * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
4629
+ * in the same transaction, so the row never changes without a durable copy of
4630
+ * what still has to reach the server.
4631
+ */
4600
4632
  function planLocalOnlyUpdateTx(input) {
4633
+ const statements = [surql.updateSet("id", [{ statement: "_00_rv += 1" }]), surql.let("updated", surql.updateMerge("id", "data"))];
4634
+ if (input.mirror) statements.push(surql.upsert("wid", "wrow"));
4635
+ statements.push(surql.returnObject([{
4636
+ key: "target",
4637
+ variable: "updated"
4638
+ }]));
4601
4639
  return {
4602
- query: surql.seal(surql.tx([
4603
- surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
4604
- surql.let("updated", surql.updateMerge("id", "data")),
4605
- surql.returnObject([{
4606
- key: "target",
4607
- variable: "updated"
4608
- }])
4609
- ])),
4610
- vars: {
4640
+ query: surql.seal(surql.tx(statements)),
4641
+ vars: input.mirror ? {
4642
+ id: input.recordId,
4643
+ data: input.data,
4644
+ wid: input.mirror.id,
4645
+ wrow: input.mirror.row
4646
+ } : {
4611
4647
  id: input.recordId,
4612
4648
  data: input.data
4613
4649
  }
4614
4650
  };
4615
4651
  }
4616
- /** The outbox row of a flushed debounced update, on its own. */
4652
+ /**
4653
+ * The outbox row of a flushed debounced update. With `mirrorId` the
4654
+ * `_00_pending_writes` row goes in the same transaction: the patch moves
4655
+ * from one table to the other, it is never in both or in neither.
4656
+ */
4617
4657
  function planDeferredOutboxRowTx(input) {
4658
+ const statements = [pendingInsert("update", true, true)];
4659
+ if (input.mirrorId) statements.push(surql.delete("wid"));
4618
4660
  return {
4619
- query: surql.seal(surql.tx([pendingInsert("update", true, true)])),
4661
+ query: surql.seal(surql.tx(statements)),
4620
4662
  vars: {
4621
4663
  id: input.recordId,
4622
4664
  mid: input.mutationId,
4623
4665
  table: input.table,
4624
4666
  createdAt: input.now,
4625
4667
  data: input.data ?? {},
4626
- before: input.before ?? null
4668
+ before: input.before ?? null,
4669
+ ...input.mirrorId ? { wid: input.mirrorId } : {}
4627
4670
  }
4628
4671
  };
4629
4672
  }
@@ -4777,6 +4820,10 @@ const debounceDelay = (input) => {
4777
4820
  const d = input.options?.debounced;
4778
4821
  return d && d !== true && typeof d.delay === "number" ? d.delay : 300;
4779
4822
  };
4823
+ const flushOnHide = (input) => {
4824
+ const d = input.options?.debounced;
4825
+ return !(d && d !== true && d.flushOnHide === false);
4826
+ };
4780
4827
  /**
4781
4828
  * One optimistic write: read the previous row (update/delete), run the local
4782
4829
  * transaction that writes the row AND its outbox entry, publish the outbox
@@ -4894,14 +4941,34 @@ function* commitWrite(kind, tx, ctx) {
4894
4941
  record
4895
4942
  };
4896
4943
  }
4897
- /** Apply locally now, remember the merged patch, write the outbox row on flush. */
4944
+ /**
4945
+ * Apply locally now, remember the merged patch, write the outbox row on flush.
4946
+ * With `flushOnHide` (the default) the merged patch is also mirrored to
4947
+ * `_00_pending_writes` in the same transaction, so a reload or a closed tab
4948
+ * inside the delay cannot lose it.
4949
+ */
4898
4950
  function* debouncedUpdate(input, key, rid, table, params) {
4899
4951
  const existing = yield fx.state.read((s) => s.pendingWrites.get(key));
4900
4952
  const now = yield fx.now();
4901
4953
  const before = existing ? existing.before : yield* readBefore(rid);
4954
+ const mirrorId = existing?.mirrorId ?? (flushOnHide(input) ? pendingWriteId(yield fx.id("mutation")) : null);
4955
+ const delay = debounceDelay(input);
4902
4956
  const local = planLocalOnlyUpdateTx({
4903
4957
  recordId: rid,
4904
- data: params
4958
+ data: params,
4959
+ mirror: mirrorId ? {
4960
+ id: parseRecordIdString(mirrorId),
4961
+ row: {
4962
+ recordId: rid,
4963
+ tableName: table,
4964
+ data: {
4965
+ ...existing?.data,
4966
+ ...params
4967
+ },
4968
+ beforeRecord: before,
4969
+ flushBy: now + delay
4970
+ }
4971
+ } : void 0
4905
4972
  });
4906
4973
  const record = (yield fx.local.execute(local.query, local.vars))?.target ?? null;
4907
4974
  yield fx.state.update(mergePendingWrite({
@@ -4910,7 +4977,8 @@ function* debouncedUpdate(input, key, rid, table, params) {
4910
4977
  recordId: input.recordId,
4911
4978
  data: params,
4912
4979
  before,
4913
- firstAt: existing?.firstAt ?? now
4980
+ firstAt: existing?.firstAt ?? now,
4981
+ mirrorId
4914
4982
  }));
4915
4983
  yield fx.state.update(markTableDirty(table));
4916
4984
  const version = typeof record?._00_rv === "number" ? record._00_rv : 0;
@@ -4944,7 +5012,7 @@ function* debouncedUpdate(input, key, rid, table, params) {
4944
5012
  records: [tuple]
4945
5013
  }
4946
5014
  });
4947
- yield fx.timer.set(`debounce:${key}`, debounceDelay(input), {
5015
+ yield fx.timer.set(`debounce:${key}`, delay, {
4948
5016
  type: "FlushWrite",
4949
5017
  key
4950
5018
  });
@@ -4967,9 +5035,15 @@ function* flushWrite(env, key) {
4967
5035
  table: pending.table,
4968
5036
  data: { ...pending.data },
4969
5037
  before: pending.before,
4970
- now
5038
+ now,
5039
+ mirrorId: pending.mirrorId ? parseRecordIdString(pending.mirrorId) : void 0
4971
5040
  });
4972
- yield fx.local.execute(insert.query, insert.vars);
5041
+ try {
5042
+ yield fx.local.execute(insert.query, insert.vars);
5043
+ } catch (error) {
5044
+ if (pending.mirrorId) yield fx.timer.set("adopt-writes", PENDING_WRITE_ORPHAN_MS, { type: "AdoptPendingWrites" });
5045
+ throw error;
5046
+ }
4973
5047
  yield fx.state.update(outboxPush({
4974
5048
  id: mutationId,
4975
5049
  type: "update",
@@ -4999,6 +5073,99 @@ function* flushWrite(env, key) {
4999
5073
  });
5000
5074
  else yield fx.dispatch({ type: "Drain" });
5001
5075
  }
5076
+ /**
5077
+ * The page is being hidden (tab switch, reload, close, backgrounded): flush
5078
+ * every mirrored pending write now rather than when its timer fires. On a
5079
+ * reload or close this races the teardown; whatever does not make it stays
5080
+ * in `_00_pending_writes` for the next boot.
5081
+ */
5082
+ function* flushPendingWrites() {
5083
+ const keys = yield fx.state.read((s) => [...s.pendingWrites.values()].filter((w) => w.mirrorId !== null).map((w) => w.key));
5084
+ for (const key of keys) {
5085
+ yield fx.timer.clear(`debounce:${key}`);
5086
+ yield fx.dispatch({
5087
+ type: "FlushWrite",
5088
+ key
5089
+ });
5090
+ }
5091
+ }
5092
+ /**
5093
+ * Move abandoned `_00_pending_writes` rows into the outbox: rows no pending
5094
+ * write of this tab holds, `PENDING_WRITE_ORPHAN_MS` past their due flush. That
5095
+ * is a tab that reloaded or closed inside the delay, or a flush of ours that
5096
+ * failed. A row not yet overdue may still be live in another tab and is looked
5097
+ * at again once it would be. A follower only nudges the leader, so a single
5098
+ * tab adopts and a patch is never queued twice.
5099
+ */
5100
+ function* adoptPendingWrites() {
5101
+ const state = yield fx.state.read((s) => s);
5102
+ let raw = [];
5103
+ try {
5104
+ const res = yield fx.local.query(loadPendingWriteRows());
5105
+ raw = Array.isArray(res?.[0]) ? res[0] : [];
5106
+ } catch {
5107
+ return;
5108
+ }
5109
+ const held = new Set([...state.pendingWrites.values()].map((w) => w.mirrorId));
5110
+ const rows = raw.map(parsePendingWriteRow).filter((r) => r !== null && !held.has(r.id));
5111
+ if (rows.length === 0) return;
5112
+ if (state.tabRole === "follower") {
5113
+ yield fx.emit({
5114
+ type: "tabs:broadcast",
5115
+ message: {
5116
+ type: "outbox-changed",
5117
+ mutationId: ""
5118
+ }
5119
+ });
5120
+ return;
5121
+ }
5122
+ const now = yield fx.now();
5123
+ let recheckAt = Infinity;
5124
+ let adopted = 0;
5125
+ for (const row of rows) {
5126
+ const due = row.flushBy + PENDING_WRITE_ORPHAN_MS;
5127
+ if (now < due) {
5128
+ recheckAt = Math.min(recheckAt, due);
5129
+ continue;
5130
+ }
5131
+ const mutationId = yield fx.id("mutation");
5132
+ const move = planDeferredOutboxRowTx({
5133
+ recordId: parseRecordIdString(row.recordId),
5134
+ mutationId: parseRecordIdString(mutationId),
5135
+ table: row.tableName,
5136
+ data: row.data,
5137
+ before: row.beforeRecord,
5138
+ now,
5139
+ mirrorId: parseRecordIdString(row.id)
5140
+ });
5141
+ try {
5142
+ yield fx.local.execute(move.query, move.vars);
5143
+ } catch (error) {
5144
+ yield fx.emit({
5145
+ type: "log",
5146
+ level: "warn",
5147
+ message: "could not move an abandoned debounced write into the outbox",
5148
+ data: {
5149
+ id: row.id,
5150
+ error
5151
+ }
5152
+ });
5153
+ continue;
5154
+ }
5155
+ yield fx.state.update(outboxPush({
5156
+ id: mutationId,
5157
+ type: "update",
5158
+ recordId: row.recordId,
5159
+ table: row.tableName,
5160
+ status: "pending",
5161
+ ackedAt: null,
5162
+ attempts: 0
5163
+ }));
5164
+ adopted += 1;
5165
+ }
5166
+ if (adopted > 0) yield fx.dispatch({ type: "Drain" });
5167
+ if (recheckAt !== Infinity) yield fx.timer.set("adopt-writes", recheckAt - now, { type: "AdoptPendingWrites" });
5168
+ }
5002
5169
 
5003
5170
  //#endregion
5004
5171
  //#region src/mutation/tray.saga.ts
@@ -5119,6 +5286,7 @@ function mutationOwnerTabId(mutationId) {
5119
5286
  /**
5120
5287
  * Boot / bucket switch: mirror the outbox table into state. Rows that can
5121
5288
  * never be replayed (a create without its payload) go straight to the tray.
5289
+ * Debounced patches a dead page left in `_00_pending_writes` are adopted next.
5122
5290
  */
5123
5291
  function* loadOutbox(env) {
5124
5292
  const res = yield fx.local.query(loadPendingRows());
@@ -5144,6 +5312,7 @@ function* loadOutbox(env) {
5144
5312
  });
5145
5313
  yield* refreshFailedCount();
5146
5314
  if (items.length > 0) yield fx.dispatch({ type: "Drain" });
5315
+ yield fx.dispatch({ type: "AdoptPendingWrites" });
5147
5316
  }
5148
5317
  function* refreshFailedCount() {
5149
5318
  try {
@@ -6686,6 +6855,11 @@ function route(env, event) {
6686
6855
  saga: flushWrite(env, event.key),
6687
6856
  lane: serial("outbox-write")
6688
6857
  };
6858
+ case "FlushPendingWrites": return { saga: flushPendingWrites() };
6859
+ case "AdoptPendingWrites": return {
6860
+ saga: adoptPendingWrites(),
6861
+ lane: serial("outbox-write")
6862
+ };
6689
6863
  case "PollTick": return {
6690
6864
  saga: pollTick(env),
6691
6865
  lane: dedupe("poll")
@@ -8141,6 +8315,16 @@ const sha1 = async (str) => {
8141
8315
  const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
8142
8316
  return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
8143
8317
  };
8318
+ /**
8319
+ * Local-only state a schema change must not throw away: the outbox, the
8320
+ * failed-mutation tray and debounced patches not yet in the outbox. Cached
8321
+ * rows refill from the server; these exist nowhere else.
8322
+ */
8323
+ const KEPT_TABLES = [
8324
+ "_00_pending_mutations",
8325
+ "_00_failed_mutations",
8326
+ "_00_pending_writes"
8327
+ ];
8144
8328
  var LocalMigrator = class {
8145
8329
  logger;
8146
8330
  constructor(localDb, logger) {
@@ -8155,8 +8339,9 @@ var LocalMigrator = class {
8155
8339
  this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
8156
8340
  return;
8157
8341
  }
8342
+ const kept = await this.readKeptRows();
8158
8343
  await this.recreateDatabase(database);
8159
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
8344
+ const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
8160
8345
  const statements = this.splitStatements(fullSchema);
8161
8346
  for (let i = 0; i < statements.length; i++) {
8162
8347
  const statement = statements[i];
@@ -8174,8 +8359,34 @@ var LocalMigrator = class {
8174
8359
  throw e;
8175
8360
  }
8176
8361
  }
8362
+ await this.restoreKeptRows(kept);
8177
8363
  await this.createHashRecord(hash);
8178
8364
  }
8365
+ async readKeptRows() {
8366
+ const kept = [];
8367
+ for (const table of KEPT_TABLES) try {
8368
+ const [rows] = await this.localDb.queryUngated(`SELECT * FROM ${table}`);
8369
+ if (Array.isArray(rows) && rows.length > 0) kept.push([table, rows]);
8370
+ } catch {}
8371
+ return kept;
8372
+ }
8373
+ async restoreKeptRows(kept) {
8374
+ for (const [table, rows] of kept) try {
8375
+ await this.localDb.queryUngated(`INSERT INTO ${table} $rows`, { rows });
8376
+ this.logger.info({
8377
+ table,
8378
+ count: rows.length,
8379
+ Category: "sp00ky-client::LocalMigrator::provision"
8380
+ }, "[Provisioning] Kept local rows across the schema change");
8381
+ } catch (error) {
8382
+ this.logger.error({
8383
+ table,
8384
+ count: rows.length,
8385
+ error,
8386
+ Category: "sp00ky-client::LocalMigrator::provision"
8387
+ }, "[Provisioning] Could not restore local rows after the schema change; they are lost");
8388
+ }
8389
+ }
8179
8390
  async isSchemaUpToDate(hash) {
8180
8391
  try {
8181
8392
  const [lastSchemaRecord] = await this.localDb.queryUngated(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
@@ -8924,6 +9135,7 @@ const SYSTEM_TABLES = [
8924
9135
  "_00_failed_mutations",
8925
9136
  "_00_schema",
8926
9137
  "_00_pending_mutations",
9138
+ "_00_pending_writes",
8927
9139
  "_00_blob",
8928
9140
  "_00_user_feature",
8929
9141
  "_00_app_release"
@@ -14013,7 +14225,7 @@ function createTabsCoordinator(config, s, host) {
14013
14225
  return new TabsCoordinator({
14014
14226
  tabId: s.tabId,
14015
14227
  fingerprint: computeTabsFingerprint({
14016
- coreVersion: "0.0.1-canary.288",
14228
+ coreVersion: "0.0.1-canary.289",
14017
14229
  schemaHash: hash53(config.schemaSurql),
14018
14230
  endpoint: config.database.endpoint ?? "",
14019
14231
  namespace: config.database.namespace,
@@ -14185,6 +14397,7 @@ function createAdapters(config, s, host, lateModules) {
14185
14397
  "window.attach": () => {
14186
14398
  if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
14187
14399
  const onPageHide = (event) => {
14400
+ host.dispatch({ type: "FlushPendingWrites" });
14188
14401
  if (event.persisted) return;
14189
14402
  host.dispatch({ type: "PageHide" });
14190
14403
  };
@@ -14192,13 +14405,17 @@ function createAdapters(config, s, host, lateModules) {
14192
14405
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
14193
14406
  host.dispatch({ type: "HeartbeatNow" });
14194
14407
  };
14408
+ const onVisibility = () => {
14409
+ if (document.visibilityState === "hidden") host.dispatch({ type: "FlushPendingWrites" });
14410
+ else onWake();
14411
+ };
14195
14412
  window.addEventListener("pagehide", onPageHide);
14196
14413
  window.addEventListener("online", onWake);
14197
- if (typeof document !== "undefined") document.addEventListener("visibilitychange", onWake);
14414
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
14198
14415
  host.setDetachWindow(() => {
14199
14416
  window.removeEventListener("pagehide", onPageHide);
14200
14417
  window.removeEventListener("online", onWake);
14201
- if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onWake);
14418
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
14202
14419
  });
14203
14420
  }
14204
14421
  };
package/dist/types.d.ts CHANGED
@@ -2002,6 +2002,20 @@ interface DebounceOptions {
2002
2002
  key?: 'recordId' | 'recordId_x_fields';
2003
2003
  /** The debounce delay in milliseconds. */
2004
2004
  delay?: number;
2005
+ /**
2006
+ * Keep the pending patch across a reload or a closed tab. Default `true`.
2007
+ *
2008
+ * Every call also writes the merged patch to `_00_pending_writes`, in the
2009
+ * same local transaction as the row itself. When the page is hidden (tab
2010
+ * switch, reload, close, app sent to the background) every pending patch is
2011
+ * moved into the outbox and pushed at once instead of waiting out `delay`.
2012
+ * A page that dies before that finishes leaves the patch in the store, and
2013
+ * the next boot moves it into the outbox.
2014
+ *
2015
+ * `false` keeps the patch in memory only, so whatever is still inside its
2016
+ * delay is lost on reload. Meant for throwaway state like a cursor position.
2017
+ */
2018
+ flushOnHide?: boolean;
2005
2019
  }
2006
2020
  //#endregion
2007
2021
  export { WebPushSubscribeOptions as $, RunOptions as A, LocalStore as At, SyncHealthStatus as B, TokenClaims as Bt, QueryTimeToLive as C, SignOutHook as Ct, RecordVersionArray as D, createAuthEventSystem as Dt, ReconnectConfig as E, AuthEventTypes as Et, StorageHealth as F, ImpersonationInfo as Ft, WebPushConfig as G, UpdateOptions as H, impersonationFromToken as Ht, StorageHealthStatus as I, ImpersonationTimerHost as It, WebPushErrorCode as J, WebPushDevice as K, StoreType as L, ImpersonationTimers as Lt, Sp00kyConfig as M, EventDefinition as Mt, Sp00kyQueryResult as N, EventSystem as Nt, RecordVersionDiff as O, RemoteDatabaseService as Ot, Sp00kyQueryResultPromise as P, IMPERSONATION_ACCESS as Pt, WebPushModule as Q, SyncHealth as R, ORIGIN_TOKEN_KEY as Rt, QueryStatusCallback as S, SignOutContext as St, QueryUpdateCallback as T, AuthEventTypeMap as Tt, PUSH_ENDPOINT_KEY as U, renewDelayMs as Ut, TimingPhase as V, decodeTokenClaims as Vt, PushDeviceKind as W, sameImpersonation as Wt, WebPushMessage as X, WebPushInfo as Y, WebPushMessageInput as Z, QueryConfig as _, isPushPayload as _t, Level$1 as a, WebPushUpdateOptions as at, QueryState as b, ImpersonationCandidate as bt, MutationCallback as c, BridgeTokenMessage as ct, PersistenceClient as d, PushNotificationAction as dt, WebPushSupport as et, PhaseStat as f, PushOp as ft, QueryAuthorityCallback as g, WebPushClientMessage as gt, PreloadRefresh as h, PushUrgency as ht, InlineRow as i, WebPushUnsupportedReason as it, ServerViewMeta as j, SealedQuery as jt, RegistrationTimings as k, Logger$1 as kt, MutationEvent as l, PUSH_PAYLOAD_VERSION as lt, PreloadOptions as m, PushPayloadKind as mt, DebounceOptions as n, WebPushSyncStatus as nt, MATERIALIZATION_SAMPLE_WINDOW as o, detectWebPushSupport as ot, PinoTransmit as p, PushPayload as pt, WebPushError as q, EventSubscriptionOptions as r, WebPushUnsubscribeOptions as rt, MembershipOutcome as s, BridgeSignOutMessage as st, ConnectionState as t, WebPushSyncOptions as tt, MutationEventType as u, PushNotification as ut, QueryConfigRecord as v, ActiveImpersonation as vt, QueryTimings as w, AuthEventSystem as wt, QueryStatus as x, SIGN_OUT_HOOK_TIMEOUT_MS as xt, QueryHash as y, AuthService as yt, SyncHealthConfig as z, SESSION_POLL_MS as zt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.288",
3
+ "version": "0.0.1-canary.289",
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.288",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.288",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.289",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.289",
80
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
81
  "@surrealdb/wasm": "^3.0.3",
82
82
  "blurhash": "^2.0.5",
@@ -120,6 +120,10 @@ await client.delete('post', 'post:abc123');
120
120
  Use `debounced` option for frequent updates (e.g., live typing):
121
121
  - `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
122
122
  - `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
123
+ - `flushOnHide` (default `true`): also keep the merged patch in the local `_00_pending_writes`
124
+ table and push it as soon as the page is hidden (tab switch, reload, close). A reload or closed
125
+ tab inside the delay no longer loses the edit: the next boot moves the leftover patch into the
126
+ outbox. Set `false` for throwaway values (cursor position) to keep the patch in memory only.
123
127
 
124
128
  ### Failed writes
125
129
 
@@ -71,6 +71,12 @@ interface UpdateOptions {
71
71
  key?: 'recordId' | 'recordId_x_fields';
72
72
  /** Debounce delay in ms */
73
73
  delay?: number;
74
+ /**
75
+ * Default true. Keep the merged patch in the local `_00_pending_writes` table and
76
+ * push it as soon as the page is hidden (tab switch, reload, close), so a reload
77
+ * inside the delay does not lose it. false: memory only.
78
+ */
79
+ flushOnHide?: boolean;
74
80
  };
75
81
  }
76
82
  ```
@@ -43,7 +43,7 @@ describe('boot', () => {
43
43
  ]);
44
44
  expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
45
45
  expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
46
- expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'StartRemote']);
46
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'AdoptPendingWrites', 'LifecycleTick', 'StartRemote']);
47
47
  // The view index is loaded before anything can paint; the first GC waits out the boot.
48
48
  expect(out.state.views).toEqual(new Map([['k', ['t:1']]]));
49
49
  expect(out.timers.get('gc')).toEqual({ ms: 30_000, event: { type: 'GcTick' } });
@@ -72,7 +72,7 @@ describe('bucketSwitch', () => {
72
72
  expect(String(e.def.id.table)).toBe('_00_query');
73
73
  // PollTick is load-bearing: the switch cleared the poll timer, and only the
74
74
  // tick re-arms it, so a sign-in without it leaves the client on LIVE alone.
75
- expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
75
+ expect(out.dispatched.map((d) => d.type)).toEqual(['PrimeCircuit', 'AdoptPendingWrites', 'EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
76
76
  });
77
77
  it('shared tabs: moves namespaces, falls back to solo on failure; blob clear on sign-out; failures are logged and the gate always reopens', async () => {
78
78
  let gateReleased = false;
@@ -22,6 +22,8 @@ describe('route', () => {
22
22
  [{ type: 'GcTick' }, 'dedupe:gc'],
23
23
  [{ type: 'Drain' }, 'serial:outbox'],
24
24
  [{ type: 'FlushWrite', key: 'k' }, 'serial:outbox-write'],
25
+ [{ type: 'FlushPendingWrites' }, undefined],
26
+ [{ type: 'AdoptPendingWrites' }, 'serial:outbox-write'],
25
27
  [{ type: 'PollTick' }, 'dedupe:poll'],
26
28
  [{ type: 'SelfHealTick' }, 'dedupe:heal'],
27
29
  [{ type: 'SyncOutcome', ok: true }, 'serial:health'],
@@ -7,7 +7,7 @@ import { fetchRows } from '../query/fetch.saga';
7
7
  import { materialize, streamUpdate } from '../query/materialize.saga';
8
8
  import { ackPrune, gcTick, lifecycleTick } from '../query/lifecycle.saga';
9
9
  import { drain } from '../mutation/push.saga';
10
- import { flushWrite } from '../mutation/write.saga';
10
+ import { adoptPendingWrites, flushPendingWrites, flushWrite } from '../mutation/write.saga';
11
11
  import { pollTick } from '../sync/poll.saga';
12
12
  import { liveChange, liveStart } from '../sync/live.saga';
13
13
  import { connectionChanged, selfHealTick, syncOutcome } from '../sync/connection.saga';
@@ -62,6 +62,10 @@ export function route(env: SagaEnv, event: RuntimeEvent): RouteTarget {
62
62
  return { saga: drain(env), lane: serial('outbox') };
63
63
  case 'FlushWrite':
64
64
  return { saga: flushWrite(env, event.key), lane: serial('outbox-write') };
65
+ case 'FlushPendingWrites':
66
+ return { saga: flushPendingWrites() };
67
+ case 'AdoptPendingWrites':
68
+ return { saga: adoptPendingWrites(), lane: serial('outbox-write') };
65
69
  case 'PollTick':
66
70
  return { saga: pollTick(env), lane: dedupe('poll') };
67
71
  case 'SelfHealTick':
@@ -280,7 +280,11 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
280
280
  'releases.init': () => undefined,
281
281
  'window.attach': () => {
282
282
  if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
283
+ // Hidden is the last moment a page reliably gets to run: a reload or a
284
+ // closed tab follows it, and a backgrounded mobile tab may be killed
285
+ // without any further event. Pending debounced writes go out then.
283
286
  const onPageHide = (event: PageTransitionEvent) => {
287
+ host.dispatch({ type: 'FlushPendingWrites' });
284
288
  if (event.persisted) return;
285
289
  host.dispatch({ type: 'PageHide' });
286
290
  };
@@ -288,13 +292,17 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
288
292
  if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
289
293
  host.dispatch({ type: 'HeartbeatNow' });
290
294
  };
295
+ const onVisibility = () => {
296
+ if (document.visibilityState === 'hidden') host.dispatch({ type: 'FlushPendingWrites' });
297
+ else onWake();
298
+ };
291
299
  window.addEventListener('pagehide', onPageHide);
292
300
  window.addEventListener('online', onWake);
293
- if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onWake);
301
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
294
302
  host.setDetachWindow(() => {
295
303
  window.removeEventListener('pagehide', onPageHide);
296
304
  window.removeEventListener('online', onWake);
297
- if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onWake);
305
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
298
306
  });
299
307
  },
300
308
  };
@@ -48,6 +48,12 @@ export const AUTH_READY_RETRY_MS = 500;
48
48
  export const AUTH_READY_MAX_ATTEMPTS = 10;
49
49
  /** Heartbeat at this fraction of the shortest ttl in state. */
50
50
  export const TTL_HEARTBEAT_FRACTION = 0.5;
51
+ /**
52
+ * How long past its due flush another tab's `_00_pending_writes` row may sit
53
+ * before it counts as abandoned (its tab died before the flush) and is moved
54
+ * into the outbox here.
55
+ */
56
+ export const PENDING_WRITE_ORPHAN_MS = 3_000;
51
57
  /**
52
58
  * Orphan body garbage collection: the first sweep waits this long after boot
53
59
  * (off the first paint and the first sync round), then repeats on the interval.