@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.
@@ -8,7 +8,19 @@ import { parseParams } from '../utils/parser';
8
8
  import { extractTablePart, parseRecordIdString } from '../utils/index';
9
9
  import type { SagaEnv } from '../query/env';
10
10
  import { columnsFor } from '../query/env';
11
- import { planCreateTx, planDeferredOutboxRowTx, planDeleteTx, planLocalOnlyUpdateTx, planUpdateTx, type LocalTx } from './rows';
11
+ import { PENDING_WRITE_ORPHAN_MS } from '../kernel/constants';
12
+ import {
13
+ loadPendingWriteRows,
14
+ parsePendingWriteRow,
15
+ pendingWriteId,
16
+ planCreateTx,
17
+ planDeferredOutboxRowTx,
18
+ planDeleteTx,
19
+ planLocalOnlyUpdateTx,
20
+ planUpdateTx,
21
+ type LocalTx,
22
+ type PendingWriteRow,
23
+ } from './rows';
12
24
 
13
25
  export interface WriteInput {
14
26
  kind: MutationEventType;
@@ -35,6 +47,10 @@ const debounceDelay = (input: WriteInput): number => {
35
47
  const d = input.options?.debounced;
36
48
  return d && d !== true && typeof d.delay === 'number' ? d.delay : 300;
37
49
  };
50
+ const flushOnHide = (input: WriteInput): boolean => {
51
+ const d = input.options?.debounced;
52
+ return !(d && d !== true && d.flushOnHide === false);
53
+ };
38
54
 
39
55
  /**
40
56
  * One optimistic write: read the previous row (update/delete), run the local
@@ -107,7 +123,12 @@ function* commitWrite(kind: MutationEventType, tx: LocalTx, ctx: CommitCtx): Sag
107
123
  return { mutationId: ctx.mutationId, record };
108
124
  }
109
125
 
110
- /** Apply locally now, remember the merged patch, write the outbox row on flush. */
126
+ /**
127
+ * Apply locally now, remember the merged patch, write the outbox row on flush.
128
+ * With `flushOnHide` (the default) the merged patch is also mirrored to
129
+ * `_00_pending_writes` in the same transaction, so a reload or a closed tab
130
+ * inside the delay cannot lose it.
131
+ */
111
132
  function* debouncedUpdate(
112
133
  input: WriteInput,
113
134
  key: string,
@@ -118,10 +139,15 @@ function* debouncedUpdate(
118
139
  const existing = (yield fx.state.read((s) => s.pendingWrites.get(key))) as PendingWrite | undefined;
119
140
  const now = (yield fx.now()) as number;
120
141
  const before = existing ? existing.before : yield* readBefore(rid);
121
- const local = planLocalOnlyUpdateTx({ recordId: rid, data: params });
142
+ const mirrorId = existing?.mirrorId ?? (flushOnHide(input) ? pendingWriteId((yield fx.id('mutation')) as string) : null);
143
+ const delay = debounceDelay(input);
144
+ const mirror = mirrorId
145
+ ? { id: parseRecordIdString(mirrorId), row: { recordId: rid, tableName: table, data: { ...existing?.data, ...params }, beforeRecord: before, flushBy: now + delay } }
146
+ : undefined;
147
+ const local = planLocalOnlyUpdateTx({ recordId: rid, data: params, mirror });
122
148
  const res = (yield fx.local.execute(local.query, local.vars)) as { target?: Record<string, unknown> } | undefined;
123
149
  const record = res?.target ?? null;
124
- yield fx.state.update(R.mergePendingWrite({ key, table, recordId: input.recordId, data: params, before, firstAt: existing?.firstAt ?? now }));
150
+ yield fx.state.update(R.mergePendingWrite({ key, table, recordId: input.recordId, data: params, before, firstAt: existing?.firstAt ?? now, mirrorId }));
125
151
  yield fx.state.update(R.markTableDirty(table));
126
152
  const version = typeof record?._00_rv === 'number' ? (record._00_rv as number) : 0;
127
153
  const tuple = { table, op: 'UPDATE' as const, id: input.recordId, record: { ...record, _00_rv: version } };
@@ -133,7 +159,7 @@ function* debouncedUpdate(
133
159
  }
134
160
  const role = (yield fx.state.read((s) => s.tabRole)) as ClientState['tabRole'];
135
161
  if (role !== 'solo') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [tuple] } });
136
- yield fx.timer.set(`debounce:${key}`, debounceDelay(input), { type: 'FlushWrite', key });
162
+ yield fx.timer.set(`debounce:${key}`, delay, { type: 'FlushWrite', key });
137
163
  return { mutationId: '', record };
138
164
  }
139
165
 
@@ -152,8 +178,15 @@ export function* flushWrite(env: SagaEnv, key: string): Saga<void> {
152
178
  data: { ...pending.data },
153
179
  before: pending.before,
154
180
  now,
181
+ mirrorId: pending.mirrorId ? parseRecordIdString(pending.mirrorId) : undefined,
155
182
  });
156
- yield fx.local.execute(insert.query, insert.vars);
183
+ try {
184
+ yield fx.local.execute(insert.query, insert.vars);
185
+ } catch (error) {
186
+ // The patch is no longer in memory; a mirrored one is still in the store.
187
+ if (pending.mirrorId) yield fx.timer.set('adopt-writes', PENDING_WRITE_ORPHAN_MS, { type: 'AdoptPendingWrites' });
188
+ throw error;
189
+ }
157
190
  yield fx.state.update(R.outboxPush({ id: mutationId, type: 'update', recordId: pending.recordId, table: pending.table, status: 'pending', ackedAt: null, attempts: 0 }));
158
191
  yield fx.emit({
159
192
  type: 'mutation:event',
@@ -164,3 +197,72 @@ export function* flushWrite(env: SagaEnv, key: string): Saga<void> {
164
197
  else yield fx.dispatch({ type: 'Drain' });
165
198
  }
166
199
 
200
+ /**
201
+ * The page is being hidden (tab switch, reload, close, backgrounded): flush
202
+ * every mirrored pending write now rather than when its timer fires. On a
203
+ * reload or close this races the teardown; whatever does not make it stays
204
+ * in `_00_pending_writes` for the next boot.
205
+ */
206
+ export function* flushPendingWrites(): Saga<void> {
207
+ const keys = (yield fx.state.read((s) => [...s.pendingWrites.values()].filter((w) => w.mirrorId !== null).map((w) => w.key))) as string[];
208
+ for (const key of keys) {
209
+ yield fx.timer.clear(`debounce:${key}`);
210
+ yield fx.dispatch({ type: 'FlushWrite', key });
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Move abandoned `_00_pending_writes` rows into the outbox: rows no pending
216
+ * write of this tab holds, `PENDING_WRITE_ORPHAN_MS` past their due flush. That
217
+ * is a tab that reloaded or closed inside the delay, or a flush of ours that
218
+ * failed. A row not yet overdue may still be live in another tab and is looked
219
+ * at again once it would be. A follower only nudges the leader, so a single
220
+ * tab adopts and a patch is never queued twice.
221
+ */
222
+ export function* adoptPendingWrites(): Saga<void> {
223
+ const state = (yield fx.state.read((s) => s)) as ClientState;
224
+ let raw: unknown[] = [];
225
+ try {
226
+ const res = (yield fx.local.query(loadPendingWriteRows())) as unknown[];
227
+ raw = Array.isArray(res?.[0]) ? (res[0] as unknown[]) : [];
228
+ } catch {
229
+ return; // An older store may not have the table yet: nothing to adopt.
230
+ }
231
+ const held = new Set([...state.pendingWrites.values()].map((w) => w.mirrorId));
232
+ const rows = raw.map(parsePendingWriteRow).filter((r): r is PendingWriteRow => r !== null && !held.has(r.id));
233
+ if (rows.length === 0) return;
234
+ if (state.tabRole === 'follower') {
235
+ yield fx.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: '' } });
236
+ return;
237
+ }
238
+ const now = (yield fx.now()) as number;
239
+ let recheckAt = Infinity;
240
+ let adopted = 0;
241
+ for (const row of rows) {
242
+ const due = row.flushBy + PENDING_WRITE_ORPHAN_MS;
243
+ if (now < due) {
244
+ recheckAt = Math.min(recheckAt, due);
245
+ continue;
246
+ }
247
+ const mutationId = (yield fx.id('mutation')) as string;
248
+ const move = planDeferredOutboxRowTx({
249
+ recordId: parseRecordIdString(row.recordId),
250
+ mutationId: parseRecordIdString(mutationId),
251
+ table: row.tableName,
252
+ data: row.data,
253
+ before: row.beforeRecord,
254
+ now,
255
+ mirrorId: parseRecordIdString(row.id),
256
+ });
257
+ try {
258
+ yield fx.local.execute(move.query, move.vars);
259
+ } catch (error) {
260
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not move an abandoned debounced write into the outbox', data: { id: row.id, error } });
261
+ continue;
262
+ }
263
+ yield fx.state.update(R.outboxPush({ id: mutationId, type: 'update', recordId: row.recordId, table: row.tableName, status: 'pending', ackedAt: null, attempts: 0 }));
264
+ adopted += 1;
265
+ }
266
+ if (adopted > 0) yield fx.dispatch({ type: 'Drain' });
267
+ if (recheckAt !== Infinity) yield fx.timer.set('adopt-writes', recheckAt - now, { type: 'AdoptPendingWrites' });
268
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { RecordId, Surreal } from 'surrealdb';
5
+ import { LocalMigrator } from './local-migrator';
6
+ import * as rows from '../../mutation/rows';
7
+
8
+ /**
9
+ * Integration: the client schema the CLI emits, provisioned by the real
10
+ * LocalMigrator into a real SurrealDB-WASM store, then every local write the
11
+ * outbox makes. The meta tables are read from the CLI's source file, so this
12
+ * cannot drift from what `spky generate` ships; the per-table `_00_rv` field
13
+ * is appended the way the codegen does it.
14
+ *
15
+ * `_00_pending_mutations` used to be SCHEMAFULL there, without the fields the
16
+ * client writes (`tableName`, `createdAt`, `v`, `beforeRecord`): SurrealDB
17
+ * failed every create, update and delete transaction on this engine.
18
+ */
19
+
20
+ const META = new URL('../../../../../apps/cli/src/meta_tables_client.surql', import.meta.url);
21
+
22
+ async function clientSchema(userSchema: string): Promise<string> {
23
+ const schema = `${userSchema}\n${await readFile(fileURLToPath(META), 'utf8')}`;
24
+ const tables = [...schema.matchAll(/DEFINE TABLE (?:IF NOT EXISTS )?(\w+)/g)].map((m) => m[1]);
25
+ return schema + tables.map((t) => `\nDEFINE FIELD _00_rv ON TABLE ${t} TYPE int DEFAULT 0 PERMISSIONS FOR select, create, update WHERE true;`).join('');
26
+ }
27
+
28
+ const DOC_V1 = `DEFINE TABLE doc SCHEMAFULL PERMISSIONS FOR select, create, update, delete WHERE true;
29
+ DEFINE FIELD title ON TABLE doc TYPE option<string>;`;
30
+ const DOC_V2 = `${DOC_V1}
31
+ DEFINE FIELD body ON TABLE doc TYPE option<string>;`;
32
+
33
+ const doc = new RecordId('doc', 'd1');
34
+ const mid = (n: string) => new RecordId('_00_pending_mutations', n);
35
+ const noop = () => {};
36
+
37
+ describe('client schema on the SurrealDB-WASM engine', () => {
38
+ let db: Surreal;
39
+ let migrator: LocalMigrator;
40
+
41
+ beforeAll(async () => {
42
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
43
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
44
+ const realFetch = globalThis.fetch;
45
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
46
+ const url = input instanceof URL ? input : new URL(String(input));
47
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
48
+ return realFetch(input as RequestInfo, init);
49
+ });
50
+ const { createWasmEngines } = await import('@surrealdb/wasm');
51
+ db = new Surreal({ engines: createWasmEngines() });
52
+ await db.connect('mem://');
53
+ await db.use({ namespace: 'n', database: 'd' });
54
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
55
+ logger.child = () => logger;
56
+ const store: any = { queryUngated: (sql: string, vars?: Record<string, unknown>) => db.query(sql, vars), getConfig: () => ({ database: 'd' }) };
57
+ migrator = new LocalMigrator(store, logger);
58
+ await migrator.provision(await clientSchema(DOC_V1));
59
+ }, 60_000);
60
+
61
+ afterAll(async () => {
62
+ await db?.close();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ const exec = (tx: rows.LocalTx) => db.query(tx.query.sql, tx.vars);
67
+ const outbox = async () => ((await db.query(rows.loadPendingRows())) as unknown[][])[0].map(rows.parsePendingRow);
68
+
69
+ it('every outbox write commits', async () => {
70
+ await exec(rows.planCreateTx({ recordId: doc, mutationId: mid('1'), table: 'doc', data: { title: 'a' }, now: 1 }));
71
+ await exec(rows.planUpdateTx({ recordId: doc, mutationId: mid('2'), table: 'doc', data: { title: 'b' }, before: { id: doc, title: 'a' }, now: 2 }));
72
+ const wid = new RecordId('_00_pending_writes', 'w1');
73
+ await exec(rows.planLocalOnlyUpdateTx({ recordId: doc, data: { title: 'c' }, mirror: { id: wid, row: { recordId: doc, tableName: 'doc', data: { title: 'c' }, beforeRecord: null, flushBy: 9 } } }));
74
+ await exec(rows.planDeferredOutboxRowTx({ recordId: doc, mutationId: mid('3'), table: 'doc', data: { title: 'c' }, before: null, now: 3, mirrorId: wid }));
75
+ await exec(rows.planDeleteTx({ recordId: doc, mutationId: mid('4'), table: 'doc', before: { id: doc, title: 'c' }, now: 4 }));
76
+
77
+ const pending = await outbox();
78
+ expect(pending.map((r) => [r?.mutationType, r?.tableName, r?.v])).toEqual([
79
+ ['create', 'doc', 2],
80
+ ['update', 'doc', 2],
81
+ ['update', 'doc', 2],
82
+ ['delete', 'doc', 2],
83
+ ]);
84
+ expect(pending[1]?.beforeRecord).toEqual({ id: doc, title: 'a' });
85
+
86
+ // A rejected one moves to the tray; an accepted one is deleted.
87
+ await exec(rows.moveToFailedTx(rows.buildFailedRow(pending[1]!, { message: 'denied', kind: 'application' }, pending[1]!.beforeRecord ?? null, 1, 5, 'full')));
88
+ const del = rows.deletePendingRow(pending[0]!.id);
89
+ await db.query(del.sql, del.vars);
90
+ expect((await outbox()).map((r) => r?.mutationType)).toEqual(['update', 'delete']);
91
+ const [tray] = (await db.query(rows.loadFailedRows())) as [Array<Record<string, unknown>>];
92
+ expect(tray).toEqual([expect.objectContaining({ mutationType: 'update', tableName: 'doc', error: { message: 'denied', kind: 'application' } })]);
93
+ });
94
+
95
+ it('a schema change keeps the outbox, the tray and pending writes, and drops the cache', async () => {
96
+ await exec(rows.planLocalOnlyUpdateTx({ recordId: doc, data: { title: 'd' }, mirror: { id: new RecordId('_00_pending_writes', 'w2'), row: { recordId: doc, tableName: 'doc', data: { title: 'd' }, beforeRecord: null, flushBy: 9 } } }));
97
+ const ids = async () =>
98
+ ((await db.query('SELECT VALUE id FROM _00_pending_mutations; SELECT VALUE id FROM _00_failed_mutations; SELECT VALUE id FROM _00_pending_writes')) as RecordId[][]).map((r) =>
99
+ r.map(String).sort()
100
+ );
101
+ const before = await ids();
102
+ expect(before.map((r) => r.length)).toEqual([2, 1, 1]);
103
+
104
+ await migrator.provision(await clientSchema(DOC_V2));
105
+ expect(await ids()).toEqual(before);
106
+ expect(((await db.query('SELECT * FROM doc')) as unknown[][])[0]).toEqual([]);
107
+ await exec(rows.planCreateTx({ recordId: new RecordId('doc', 'd2'), mutationId: mid('5'), table: 'doc', data: { title: 'e', body: 'x' }, now: 6 }));
108
+ expect(await outbox()).toHaveLength(3);
109
+ });
110
+ });
@@ -15,6 +15,13 @@ export const sha1 = async (str: string): Promise<string> => {
15
15
  .join('');
16
16
  };
17
17
 
18
+ /**
19
+ * Local-only state a schema change must not throw away: the outbox, the
20
+ * failed-mutation tray and debounced patches not yet in the outbox. Cached
21
+ * rows refill from the server; these exist nowhere else.
22
+ */
23
+ const KEPT_TABLES = ['_00_pending_mutations', '_00_failed_mutations', '_00_pending_writes'] as const;
24
+
18
25
  export class LocalMigrator {
19
26
  private logger: Logger;
20
27
 
@@ -40,6 +47,7 @@ export class LocalMigrator {
40
47
  return;
41
48
  }
42
49
 
50
+ const kept = await this.readKeptRows();
43
51
  await this.recreateDatabase(database);
44
52
 
45
53
  const systemSchema = `
@@ -49,6 +57,7 @@ export class LocalMigrator {
49
57
  DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
58
  DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
51
59
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
60
+ DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
52
61
  DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
53
62
  `;
54
63
  const fullSchema = schemaSurql + '\n' + systemSchema;
@@ -88,9 +97,40 @@ export class LocalMigrator {
88
97
  }
89
98
  }
90
99
 
100
+ await this.restoreKeptRows(kept);
91
101
  await this.createHashRecord(hash);
92
102
  }
93
103
 
104
+ private async readKeptRows(): Promise<Array<[string, unknown[]]>> {
105
+ const kept: Array<[string, unknown[]]> = [];
106
+ for (const table of KEPT_TABLES) {
107
+ try {
108
+ const [rows] = await this.localDb.queryUngated<[unknown[]]>(`SELECT * FROM ${table}`);
109
+ if (Array.isArray(rows) && rows.length > 0) kept.push([table, rows]);
110
+ } catch {
111
+ // A fresh store has no such table yet: nothing to keep.
112
+ }
113
+ }
114
+ return kept;
115
+ }
116
+
117
+ private async restoreKeptRows(kept: Array<[string, unknown[]]>): Promise<void> {
118
+ for (const [table, rows] of kept) {
119
+ try {
120
+ await this.localDb.queryUngated(`INSERT INTO ${table} $rows`, { rows });
121
+ this.logger.info(
122
+ { table, count: rows.length, Category: 'sp00ky-client::LocalMigrator::provision' },
123
+ '[Provisioning] Kept local rows across the schema change'
124
+ );
125
+ } catch (error) {
126
+ this.logger.error(
127
+ { table, count: rows.length, error, Category: 'sp00ky-client::LocalMigrator::provision' },
128
+ '[Provisioning] Could not restore local rows after the schema change; they are lost'
129
+ );
130
+ }
131
+ }
132
+ }
133
+
94
134
  private async isSchemaUpToDate(hash: string): Promise<boolean> {
95
135
  try {
96
136
  const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
@@ -66,6 +66,8 @@ const SYSTEM_TABLES = [
66
66
  '_00_failed_mutations',
67
67
  '_00_schema',
68
68
  '_00_pending_mutations',
69
+ // Debounced patches still inside their delay; read on every boot.
70
+ '_00_pending_writes',
69
71
  // Blob cache manifest. Read before its first write on every boot (reconcile
70
72
  // asks for the ids it found in OPFS), so it has to exist up front like the
71
73
  // rest — otherwise the very first reconcile throws and the cache runs cold.
@@ -91,6 +91,8 @@ export interface PendingWrite {
91
91
  readonly data: Readonly<Record<string, unknown>>;
92
92
  readonly before: Readonly<Record<string, unknown>> | null;
93
93
  readonly firstAt: number;
94
+ /** Its durable copy in `_00_pending_writes`; null when `flushOnHide` is off. */
95
+ readonly mirrorId: string | null;
94
96
  }
95
97
 
96
98
  export interface SyncSlice {
@@ -221,10 +221,12 @@ describe('outbox', () => {
221
221
  expect(R.outboxPruneAcked(100, 50)(s1)).toBe(s1);
222
222
  });
223
223
  it('pending writes merge per key and clear', () => {
224
- const w = { key: 'k', table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: { a: 0 }, firstAt: 1 };
224
+ const w = { key: 'k', table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: { a: 0 }, firstAt: 1, mirrorId: null };
225
225
  let s = R.mergePendingWrite(w)(buildState());
226
- s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9 })(s);
227
- expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 } });
226
+ s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9, mirrorId: '_00_pending_writes:m' })(s);
227
+ expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 }, mirrorId: '_00_pending_writes:m' });
228
+ s = R.mergePendingWrite({ ...w, mirrorId: '_00_pending_writes:other' })(s);
229
+ expect(s.pendingWrites.get('k')!.mirrorId).toBe('_00_pending_writes:m');
228
230
  s = R.clearPendingWrite('k')(s);
229
231
  expect(s.pendingWrites.size).toBe(0);
230
232
  expect(R.clearPendingWrite('k')(s)).toBe(s);
@@ -420,7 +420,7 @@ export const mergePendingWrite =
420
420
  const pendingWrites = new Map(s.pendingWrites);
421
421
  pendingWrites.set(
422
422
  write.key,
423
- prev ? { ...prev, data: { ...prev.data, ...write.data } } : write
423
+ prev ? { ...prev, data: { ...prev.data, ...write.data }, mirrorId: prev.mirrorId ?? write.mirrorId } : write
424
424
  );
425
425
  return { ...s, pendingWrites };
426
426
  };
@@ -158,7 +158,7 @@ describe('view index selectors', () => {
158
158
  const s = R.compose(
159
159
  R.reloadViews(new Map([['v', ['t:vouched']]])),
160
160
  R.outboxReplace([buildOutboxItem({ recordId: 't:outbox' })]),
161
- (st) => ({ ...st, pendingWrites: new Map([['k', { key: 'k', table: 't', recordId: 't:debounced', data: {}, before: null, firstAt: 0 }]]) })
161
+ (st) => ({ ...st, pendingWrites: new Map([['k', { key: 'k', table: 't', recordId: 't:debounced', data: {}, before: null, firstAt: 0, mirrorId: null }]]) })
162
162
  )(buildState([e('a', { remoteArray: [['t:member', 1]], subqueryRemoteArray: [['t:child', 1]] })]));
163
163
  const keep = S.retained(s);
164
164
  for (const id of ['t:vouched', 't:member', 't:child', 't:outbox', 't:debounced', '_00_view:x']) expect(keep(id)).toBe(true);
@@ -11,7 +11,7 @@ describe('setRole', () => {
11
11
  it('leader: loads the outbox and starts live/poll/registrations; follower: stops its timers; same role is a no-op', async () => {
12
12
  const leader = await runPure(setRole(env, 'leader'), { state: buildState(), handlers: { 'local.query': () => [] } });
13
13
  expect(leader.state.tabRole).toBe('leader');
14
- expect(leader.dispatched).toEqual([{ type: 'LiveStart' }, { type: 'PollTick' }, { type: 'EnsureRegistered' }]);
14
+ expect(leader.dispatched).toEqual([{ type: 'AdoptPendingWrites' }, { type: 'LiveStart' }, { type: 'PollTick' }, { type: 'EnsureRegistered' }]);
15
15
  const follower = await runPure(setRole(env, 'follower'), { state: R.patchSync({ liveUuid: 'u' })(buildState()) });
16
16
  expect(follower.state.tabRole).toBe('follower');
17
17
  expect(follower.state.sync.liveUuid).toBeNull();
package/src/types.ts CHANGED
@@ -839,4 +839,18 @@ export interface DebounceOptions {
839
839
  key?: 'recordId' | 'recordId_x_fields';
840
840
  /** The debounce delay in milliseconds. */
841
841
  delay?: number;
842
+ /**
843
+ * Keep the pending patch across a reload or a closed tab. Default `true`.
844
+ *
845
+ * Every call also writes the merged patch to `_00_pending_writes`, in the
846
+ * same local transaction as the row itself. When the page is hidden (tab
847
+ * switch, reload, close, app sent to the background) every pending patch is
848
+ * moved into the outbox and pushed at once instead of waiting out `delay`.
849
+ * A page that dies before that finishes leaves the patch in the store, and
850
+ * the next boot moves it into the outbox.
851
+ *
852
+ * `false` keeps the patch in memory only, so whatever is still inside its
853
+ * delay is lost on reload. Meant for throwaway state like a cursor position.
854
+ */
855
+ flushOnHide?: boolean;
842
856
  }