@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.
@@ -21,6 +21,8 @@ export type RuntimeEvent =
21
21
  | { type: 'GcTick' }
22
22
  | { type: 'Drain' }
23
23
  | { type: 'FlushWrite'; key: string }
24
+ | { type: 'FlushPendingWrites' }
25
+ | { type: 'AdoptPendingWrites' }
24
26
  | { type: 'PollTick' }
25
27
  | { type: 'SelfHealTick' }
26
28
  | { type: 'HeartbeatNow' }
@@ -0,0 +1,175 @@
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 sqlite3InitModule from '@sqlite.org/sqlite-wasm';
5
+ import { RecordId, Surreal } from 'surrealdb';
6
+ import { defaultEnv } from '../query/env';
7
+ import { runPure } from '../testing/run-pure';
8
+ import { buildState } from '../testing/build';
9
+ import type { ClientState } from '../state/client-state';
10
+ import type { SealedQuery } from '../utils/surql';
11
+ import type { Vars } from '../kernel/effects';
12
+ import { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
13
+ import { stubTransport } from '../services/database/sqlite-transport.fixture';
14
+ import { PENDING_WRITE_ORPHAN_MS } from '../kernel/constants';
15
+ import { loadPendingRows, parsePendingRow } from './rows';
16
+ import { mintMutationId } from './mutation-id';
17
+ import { adoptPendingWrites, flushWrite, write } from './write.saga';
18
+
19
+ /**
20
+ * Integration: a debounced update must survive the page going away inside its
21
+ * delay. These run the real sagas and execute their transactions on BOTH local
22
+ * engines: a real in-memory SQLite behind the SqliteCacheEngine, and a real
23
+ * SurrealDB-WASM `mem://` store whose `_00_pending_writes` table was never
24
+ * defined (a store provisioned before the table existed).
25
+ *
26
+ * On each: the patch is mirrored to `_00_pending_writes` beside the row; a
27
+ * timely flush moves it into the outbox; and when the page died first, a fresh
28
+ * page (empty memory) adopts the mirror into a replayable outbox row.
29
+ */
30
+
31
+ const env = defaultEnv({ tables: [{ name: 'doc', columns: { title: { type: 'string', optional: true } } }] } as any);
32
+ const doc = new RecordId('doc', 'd1');
33
+
34
+ interface Engine {
35
+ query(sql: string, vars?: Vars): Promise<unknown>;
36
+ execute(query: SealedQuery<unknown>, vars: Vars): Promise<unknown>;
37
+ }
38
+
39
+ const run = <R>(engine: Engine, saga: Generator<any, R, any>, state: ClientState, now: number) =>
40
+ runPure(saga, {
41
+ state,
42
+ now,
43
+ handlers: {
44
+ 'local.query': (e: any) => engine.query(e.sql, e.vars),
45
+ 'local.execute': (e: any) => engine.execute(e.query, e.vars),
46
+ 'ssp.ingest': () => undefined,
47
+ id: () => mintMutationId(state.tabId),
48
+ },
49
+ });
50
+
51
+ async function scenario(engine: Engine, readRow: () => Promise<Record<string, unknown> | null>): Promise<void> {
52
+ const typed = async (title: string, state: ClientState, now: number) =>
53
+ run(engine, write(env, { kind: 'update', recordId: 'doc:d1', data: { title }, options: { debounced: { delay: 1_000 } } }), state, now);
54
+ const mirrors = async () => ((await engine.query('SELECT * FROM _00_pending_writes')) as unknown[][])[0] ?? [];
55
+ const outbox = async () => (((await engine.query(loadPendingRows())) as unknown[][])[0] ?? []).map(parsePendingRow);
56
+
57
+ // Booting with nothing to adopt is a no-op, even before the table exists.
58
+ const boot = await run(engine, adoptPendingWrites(), buildState(), 0);
59
+ expect(boot.dispatched).toEqual([]);
60
+
61
+ // Typing inside the delay: the row changes at once, the patch is mirrored beside it.
62
+ const a = await typed('He', buildState(), 1_000);
63
+ const b = await typed('Hello', a.state, 1_100);
64
+ expect((await readRow())?.title).toBe('Hello');
65
+ expect(await mirrors()).toHaveLength(1);
66
+ expect(await outbox()).toEqual([]);
67
+
68
+ // The timer fires: the patch moves into the outbox, the mirror is gone.
69
+ const flushed = await run(engine, flushWrite(env, 'doc:d1::title'), b.state, 2_100);
70
+ expect(await mirrors()).toEqual([]);
71
+ expect(await outbox()).toEqual([expect.objectContaining({ mutationType: 'update', recordId: 'doc:d1', data: { title: 'Hello' } })]);
72
+ await engine.query('DELETE _00_pending_mutations');
73
+
74
+ // Typing again, then the page dies before the flush: memory is gone, the mirror is not.
75
+ await typed('Hello world', flushed.state, 3_000);
76
+ const reloaded = { ...buildState(), tabId: 'tab-after-reload' };
77
+ const early = await run(engine, adoptPendingWrites(), reloaded, 3_100);
78
+ expect(early.timers.get('adopt-writes')).toEqual({ ms: 4_000 + PENDING_WRITE_ORPHAN_MS - 3_100, event: { type: 'AdoptPendingWrites' } });
79
+ expect(await outbox()).toEqual([]);
80
+
81
+ const adopted = await run(engine, adoptPendingWrites(), reloaded, 4_000 + PENDING_WRITE_ORPHAN_MS);
82
+ expect(adopted.dispatched).toEqual([{ type: 'Drain' }]);
83
+ expect(await mirrors()).toEqual([]);
84
+ const rows = await outbox();
85
+ expect(rows).toEqual([expect.objectContaining({ mutationType: 'update', recordId: 'doc:d1', data: { title: 'Hello world' } })]);
86
+ expect(adopted.state.outbox.map((i) => i.id)).toEqual([rows[0]!.id]);
87
+ expect((await readRow())?.title).toBe('Hello world');
88
+ }
89
+
90
+ const noop = () => {};
91
+
92
+ describe('debounced writes survive a dead page: SQLite engine', () => {
93
+ let db: any;
94
+
95
+ beforeAll(async () => {
96
+ const sqlite3: any = await sqlite3InitModule();
97
+ db = new sqlite3.oo1.DB(':memory:', 'c');
98
+ });
99
+
100
+ afterAll(() => db?.close());
101
+
102
+ it('mirrors, flushes, and adopts', async () => {
103
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
104
+ logger.child = () => logger;
105
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
106
+ stubTransport(engine, (type, payload: any) => {
107
+ switch (type) {
108
+ case 'open':
109
+ // As the worker does: system tables exist from the open on.
110
+ for (const t of payload.systemTables as string[]) db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
111
+ return { persisted: true };
112
+ case 'exec':
113
+ return { rows: db.exec({ sql: payload.sql, bind: payload.bind, rowMode: 'object', returnValue: 'resultRows' }) };
114
+ case 'run':
115
+ db.exec({ sql: payload.sql, bind: payload.bind });
116
+ return {};
117
+ case 'batch':
118
+ for (const stmt of payload as { sql: string; bind?: unknown[] }[]) db.exec({ sql: stmt.sql, bind: stmt.bind });
119
+ return {};
120
+ default:
121
+ return {};
122
+ }
123
+ });
124
+ await engine.connect('anon');
125
+ await engine.upsert('doc', doc, { title: '' }, 'replace');
126
+ await scenario(
127
+ { query: (sql, vars) => engine.query(sql, vars), execute: (q, v) => engine.execute(q, v) },
128
+ async () => ((await engine.getById('doc', doc)) as Record<string, unknown>) ?? null
129
+ );
130
+ });
131
+ });
132
+
133
+ describe('debounced writes survive a dead page: SurrealDB-WASM engine', () => {
134
+ let db: Surreal;
135
+
136
+ beforeAll(async () => {
137
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
138
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
139
+ const realFetch = globalThis.fetch;
140
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
141
+ const url = input instanceof URL ? input : new URL(String(input));
142
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
143
+ return realFetch(input as RequestInfo, init);
144
+ });
145
+ const { createWasmEngines } = await import('@surrealdb/wasm');
146
+ db = new Surreal({ engines: createWasmEngines() });
147
+ await db.connect('mem://');
148
+ await db.use({ namespace: 'n', database: 'd' });
149
+ await db.query(`
150
+ DEFINE TABLE doc SCHEMAFULL;
151
+ DEFINE FIELD title ON doc TYPE option<string>;
152
+ DEFINE FIELD _00_rv ON doc TYPE option<int>;
153
+ DEFINE TABLE _00_pending_mutations SCHEMALESS;
154
+ CREATE doc:d1 SET title = '';
155
+ `);
156
+ }, 60_000);
157
+
158
+ afterAll(async () => {
159
+ await db?.close();
160
+ vi.unstubAllGlobals();
161
+ });
162
+
163
+ it('mirrors, flushes, and adopts', async () => {
164
+ await scenario(
165
+ {
166
+ query: (sql, vars) => db.query(sql, vars) as unknown as Promise<unknown>,
167
+ execute: async (q, v) => q.extract((await db.query(q.sql, v)) as unknown[]),
168
+ },
169
+ async () => {
170
+ const [row] = (await db.query('SELECT * FROM ONLY $id', { id: doc })) as [Record<string, unknown>];
171
+ return row ?? null;
172
+ }
173
+ );
174
+ });
175
+ });
@@ -267,9 +267,9 @@ describe('loadOutbox / refreshFailedCount', () => {
267
267
  expect(out.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:rolled-back', mutationId: '_00_pending_mutations:bad' }));
268
268
  expect(out.emitted).toContainEqual(expect.objectContaining({ type: 'mutation:rolled-back', mutationId: '' }));
269
269
  expect(out.state.failedCount).toBe(4);
270
- expect(out.dispatched).toEqual([{ type: 'Drain' }]);
270
+ expect(out.dispatched).toEqual([{ type: 'Drain' }, { type: 'AdoptPendingWrites' }]);
271
271
  const empty = await runPure(loadOutbox(env), { state: buildState(), handlers: { 'local.query': () => [] } });
272
- expect(empty.dispatched).toEqual([]);
272
+ expect(empty.dispatched).toEqual([{ type: 'AdoptPendingWrites' }]);
273
273
  const missingTable = await runPure(refreshFailedCount(), {
274
274
  state: R.setFailedCount(2)(buildState()),
275
275
  handlers: {
@@ -26,6 +26,7 @@ import {
26
26
  /**
27
27
  * Boot / bucket switch: mirror the outbox table into state. Rows that can
28
28
  * never be replayed (a create without its payload) go straight to the tray.
29
+ * Debounced patches a dead page left in `_00_pending_writes` are adopted next.
29
30
  */
30
31
  export function* loadOutbox(env: SagaEnv): Saga<void> {
31
32
  const res = (yield fx.local.query(loadPendingRows())) as unknown[];
@@ -51,6 +52,7 @@ export function* loadOutbox(env: SagaEnv): Saga<void> {
51
52
  }
52
53
  yield* refreshFailedCount();
53
54
  if (items.length > 0) yield fx.dispatch({ type: 'Drain' });
55
+ yield fx.dispatch({ type: 'AdoptPendingWrites' });
54
56
  }
55
57
 
56
58
  export function* refreshFailedCount(): Saga<void> {
@@ -103,6 +103,36 @@ describe('debounced update transactions', () => {
103
103
  expect(deferred.query.sql).toBe("BEGIN TRANSACTION;\nCREATE ONLY $mid SET mutationType = 'update', recordId = $id, tableName = $table, createdAt = $createdAt, v = 2, data = $data, beforeRecord = $before;\nCOMMIT TRANSACTION;");
104
104
  expect(rows.planDeferredOutboxRowTx({ recordId: rid('thing', '1'), mutationId: rid('_00_pending_mutations', 'm'), table: 'thing', now: 3 }).vars).toMatchObject({ data: {}, before: null });
105
105
  });
106
+
107
+ it('with a mirror: the patch is upserted beside the row, and the flush moves it in one transaction', () => {
108
+ const wrow = { recordId: rid('thing', '1'), tableName: 'thing', data: { a: 1, b: 2 }, beforeRecord: null, flushBy: 9 };
109
+ const local = rows.planLocalOnlyUpdateTx({ recordId: rid('thing', '1'), data: { b: 2 }, mirror: { id: rid('_00_pending_writes', 'w'), row: wrow } });
110
+ expect(local.query.sql).toBe(
111
+ 'BEGIN TRANSACTION;\nUPDATE $id SET _00_rv += 1;LET $updated = (UPDATE ONLY $id MERGE $data);UPSERT ONLY $wid REPLACE $wrow;RETURN {target: $updated};\nCOMMIT TRANSACTION;'
112
+ );
113
+ expect(local.vars).toEqual({ id: rid('thing', '1'), data: { b: 2 }, wid: rid('_00_pending_writes', 'w'), wrow });
114
+ expect(local.query.extract([null, 1, 2, 3, { target: 'x' }])).toEqual({ target: 'x' });
115
+ const moved = rows.planDeferredOutboxRowTx({ recordId: rid('thing', '1'), mutationId: rid('_00_pending_mutations', 'm'), table: 'thing', data: { a: 1 }, now: 3, mirrorId: rid('_00_pending_writes', 'w') });
116
+ expect(moved.query.sql).toContain('beforeRecord = $before;DELETE $wid;\nCOMMIT');
117
+ expect(moved.vars.wid).toEqual(rid('_00_pending_writes', 'w'));
118
+ });
119
+
120
+ it('pending write ids and rows', () => {
121
+ expect(rows.pendingWriteId('_00_pending_mutations:0000000000001_0001_tab')).toBe('_00_pending_writes:0000000000001_0001_tab');
122
+ expect(rows.pendingWriteId('mutation-1')).toBe('_00_pending_writes:mutation-1');
123
+ expect(rows.parsePendingWriteRow({ id: '_00_pending_writes:⟨w-1⟩', recordId: rid('thing', '1'), data: { a: 1 }, beforeRecord: { a: 0 }, flushBy: 5 })).toEqual({
124
+ id: '_00_pending_writes:w-1',
125
+ recordId: 'thing:1',
126
+ tableName: 'thing',
127
+ data: { a: 1 },
128
+ beforeRecord: { a: 0 },
129
+ flushBy: 5,
130
+ });
131
+ expect(rows.parsePendingWriteRow({ id: 'w', recordId: 'thing:1', tableName: 'thing', data: {} })).toMatchObject({ beforeRecord: null, flushBy: 0 });
132
+ expect(rows.parsePendingWriteRow({ id: 'w', recordId: 'thing:1' })).toBeNull();
133
+ expect(rows.parsePendingWriteRow({ id: 'w', data: {} })).toBeNull();
134
+ expect(rows.parsePendingWriteRow('junk')).toBeNull();
135
+ });
106
136
  });
107
137
 
108
138
  describe('remoteBatch', () => {
@@ -10,6 +10,8 @@ import { parseQueryParams } from '../utils/parser';
10
10
 
11
11
  export const PENDING_TABLE = '_00_pending_mutations';
12
12
  export const FAILED_TABLE = '_00_failed_mutations';
13
+ /** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
14
+ export const PENDING_WRITES_TABLE = '_00_pending_writes';
13
15
  export const PENDING_ROW_VERSION = 2;
14
16
 
15
17
  /** One `_00_pending_mutations` row as this client writes it (v2). */
@@ -117,7 +119,42 @@ export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
117
119
  attempts: 0,
118
120
  });
119
121
 
122
+ /**
123
+ * One `_00_pending_writes` row: the merged patch of a debounced update that
124
+ * has not reached the outbox yet. `flushBy` is when the writing tab's timer
125
+ * is due to move it there.
126
+ */
127
+ export interface PendingWriteRow {
128
+ id: string;
129
+ recordId: string;
130
+ tableName: string;
131
+ data: Record<string, unknown>;
132
+ beforeRecord: Record<string, unknown> | null;
133
+ flushBy: number;
134
+ }
135
+
136
+ /** The `_00_pending_writes` id for a minted mutation id (same suffix). */
137
+ export const pendingWriteId = (mutationId: string): string =>
138
+ `${PENDING_WRITES_TABLE}:${mutationId.includes(':') ? mutationId.slice(mutationId.indexOf(':') + 1) : mutationId}`;
139
+
140
+ export function parsePendingWriteRow(row: unknown): PendingWriteRow | null {
141
+ if (!row || typeof row !== 'object') return null;
142
+ const r = row as Record<string, unknown>;
143
+ if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
144
+ if (!r.data || typeof r.data !== 'object') return null;
145
+ const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
146
+ return {
147
+ id: storedIdString(r.id),
148
+ recordId,
149
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
150
+ data: r.data as Record<string, unknown>,
151
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
152
+ flushBy: typeof r.flushBy === 'number' ? r.flushBy : 0,
153
+ };
154
+ }
155
+
120
156
  export const loadPendingRows = (): string => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
157
+ export const loadPendingWriteRows = (): string => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
121
158
  export const loadFailedRows = (): string => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
122
159
 
123
160
  // ---- local write transactions ------------------------------------------------
@@ -199,24 +236,36 @@ export function planDeleteTx(input: WritePlanInput): LocalTx {
199
236
  };
200
237
  }
201
238
 
202
- /** Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later. */
203
- export function planLocalOnlyUpdateTx(input: { recordId: RecordId<string>; data: Record<string, unknown> }): LocalTx {
239
+ /**
240
+ * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
241
+ * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
242
+ * in the same transaction, so the row never changes without a durable copy of
243
+ * what still has to reach the server.
244
+ */
245
+ export function planLocalOnlyUpdateTx(input: {
246
+ recordId: RecordId<string>;
247
+ data: Record<string, unknown>;
248
+ mirror?: { id: RecordId<string>; row: Omit<PendingWriteRow, 'id' | 'recordId'> & { recordId: RecordId<string> } };
249
+ }): LocalTx {
250
+ const statements = [surql.updateSet('id', [{ statement: '_00_rv += 1' }]), surql.let('updated', surql.updateMerge('id', 'data'))];
251
+ if (input.mirror) statements.push(surql.upsert('wid', 'wrow'));
252
+ statements.push(surql.returnObject([{ key: 'target', variable: 'updated' }]));
204
253
  return {
205
- query: surql.seal<unknown>(
206
- surql.tx([
207
- surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
208
- surql.let('updated', surql.updateMerge('id', 'data')),
209
- surql.returnObject([{ key: 'target', variable: 'updated' }]),
210
- ])
211
- ),
212
- vars: { id: input.recordId, data: input.data },
254
+ query: surql.seal<unknown>(surql.tx(statements)),
255
+ vars: input.mirror ? { id: input.recordId, data: input.data, wid: input.mirror.id, wrow: input.mirror.row } : { id: input.recordId, data: input.data },
213
256
  };
214
257
  }
215
258
 
216
- /** The outbox row of a flushed debounced update, on its own. */
217
- export function planDeferredOutboxRowTx(input: WritePlanInput): LocalTx {
259
+ /**
260
+ * The outbox row of a flushed debounced update. With `mirrorId` the
261
+ * `_00_pending_writes` row goes in the same transaction: the patch moves
262
+ * from one table to the other, it is never in both or in neither.
263
+ */
264
+ export function planDeferredOutboxRowTx(input: WritePlanInput & { mirrorId?: RecordId<string> }): LocalTx {
265
+ const statements = [pendingInsert('update', true, true)];
266
+ if (input.mirrorId) statements.push(surql.delete('wid'));
218
267
  return {
219
- query: surql.seal<unknown>(surql.tx([pendingInsert('update', true, true)])),
268
+ query: surql.seal<unknown>(surql.tx(statements)),
220
269
  vars: {
221
270
  id: input.recordId,
222
271
  mid: input.mutationId,
@@ -224,6 +273,7 @@ export function planDeferredOutboxRowTx(input: WritePlanInput): LocalTx {
224
273
  createdAt: input.now,
225
274
  data: input.data ?? {},
226
275
  before: input.before ?? null,
276
+ ...(input.mirrorId ? { wid: input.mirrorId } : {}),
227
277
  },
228
278
  };
229
279
  }
@@ -3,7 +3,8 @@ import { RecordId } from 'surrealdb';
3
3
  import { runPure } from '../testing/run-pure';
4
4
  import { buildEntry, buildState } from '../testing/build';
5
5
  import { defaultEnv } from '../query/env';
6
- import { flushWrite, write } from './write.saga';
6
+ import { adoptPendingWrites, flushPendingWrites, flushWrite, write } from './write.saga';
7
+ import * as R from '../state/reducers';
7
8
 
8
9
  const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {}, b: {} } }] } as any);
9
10
  const rid = new RecordId('thing', '1');
@@ -160,4 +161,156 @@ describe('write', () => {
160
161
  });
161
162
  expect(leaderDebounced.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ op: 'UPDATE' })] } });
162
163
  });
164
+
165
+ it('flushOnHide (default): the merged patch is mirrored in the same tx, and the flush moves it into the outbox', async () => {
166
+ const now = 1_000;
167
+ const first = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: true } }), {
168
+ state: withQuery(),
169
+ now,
170
+ handlers: {
171
+ 'local.query': () => [{ id: rid, a: 0, _00_rv: 1 }],
172
+ 'local.execute': (e: any) => {
173
+ expect(e.query.sql).toContain('UPSERT ONLY $wid REPLACE $wrow');
174
+ expect(e.vars.wid).toEqual(new RecordId('_00_pending_writes', 'mutation-1'));
175
+ expect(e.vars.wrow).toEqual({ recordId: rid, tableName: 'thing', data: { a: 1 }, beforeRecord: { id: rid, a: 0, _00_rv: 1 }, flushBy: now + 300 });
176
+ return { target: { id: rid, a: 1, _00_rv: 2 } };
177
+ },
178
+ 'ssp.ingest': () => undefined,
179
+ },
180
+ });
181
+ const key = 'thing:1::a';
182
+ expect(first.state.pendingWrites.get(key)!.mirrorId).toBe('_00_pending_writes:mutation-1');
183
+ const second = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 7 }, options: { debounced: { delay: 50 } } }), {
184
+ state: first.state,
185
+ now: now + 10,
186
+ handlers: {
187
+ 'local.execute': (e: any) => {
188
+ expect(e.vars.wid).toEqual(new RecordId('_00_pending_writes', 'mutation-1'));
189
+ expect(e.vars.wrow).toMatchObject({ data: { a: 7 }, flushBy: now + 60 });
190
+ return { target: { id: rid, a: 7, _00_rv: 3 } };
191
+ },
192
+ 'ssp.ingest': () => undefined,
193
+ },
194
+ });
195
+ expect(second.log.filter((e) => e.kind === 'id')).toHaveLength(0);
196
+ const flushed = await runPure(flushWrite(env, key), {
197
+ state: second.state,
198
+ handlers: {
199
+ 'local.execute': (e: any) => {
200
+ expect(e.query.sql).toContain('DELETE $wid');
201
+ expect(e.vars).toMatchObject({ data: { a: 7 }, wid: new RecordId('_00_pending_writes', 'mutation-1') });
202
+ },
203
+ },
204
+ });
205
+ expect(flushed.state.pendingWrites.size).toBe(0);
206
+ expect(flushed.state.outbox).toHaveLength(1);
207
+ const armed: unknown[] = [];
208
+ const closed = runPure(flushWrite(env, key), {
209
+ state: second.state,
210
+ handlers: {
211
+ 'local.execute': () => {
212
+ throw new Error('store closed');
213
+ },
214
+ 'timer.set': (e: any) => void armed.push([e.key, e.ms, e.event]),
215
+ },
216
+ });
217
+ await expect(closed).rejects.toThrow('store closed');
218
+ expect(armed).toEqual([['adopt-writes', 3_000, { type: 'AdoptPendingWrites' }]]);
219
+ });
220
+
221
+ it('flushOnHide: false keeps the patch in memory only', async () => {
222
+ const out = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: { flushOnHide: false } } }), {
223
+ state: withQuery(),
224
+ handlers: {
225
+ 'local.query': () => [],
226
+ 'local.execute': (e: any) => {
227
+ expect(e.query.sql).not.toContain('UPSERT');
228
+ expect(e.vars.wid).toBeUndefined();
229
+ },
230
+ 'ssp.ingest': () => undefined,
231
+ },
232
+ });
233
+ expect(out.state.pendingWrites.get('thing:1::a')!.mirrorId).toBeNull();
234
+ const flushed = await runPure(flushWrite(env, 'thing:1::a'), {
235
+ state: out.state,
236
+ handlers: { 'local.execute': (e: any) => expect(e.query.sql).not.toContain('DELETE') },
237
+ });
238
+ expect(flushed.state.outbox).toHaveLength(1);
239
+ });
240
+ });
241
+
242
+ describe('flushPendingWrites', () => {
243
+ it('flushes every mirrored pending write now, leaves memory-only ones to their timer', async () => {
244
+ const base = { table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: null, firstAt: 0 };
245
+ const state = R.compose(
246
+ R.mergePendingWrite({ ...base, key: 'k1', mirrorId: '_00_pending_writes:1' }),
247
+ R.mergePendingWrite({ ...base, key: 'k2', mirrorId: null }),
248
+ R.mergePendingWrite({ ...base, key: 'k3', mirrorId: '_00_pending_writes:3' })
249
+ )(buildState());
250
+ const out = await runPure(flushPendingWrites(), { state });
251
+ expect(out.log.filter((e) => e.kind === 'timer.clear').map((e: any) => e.key)).toEqual(['debounce:k1', 'debounce:k3']);
252
+ expect(out.dispatched).toEqual([{ type: 'FlushWrite', key: 'k1' }, { type: 'FlushWrite', key: 'k3' }]);
253
+ const none = await runPure(flushPendingWrites(), { state: buildState() });
254
+ expect(none.dispatched).toEqual([]);
255
+ });
256
+ });
257
+
258
+ describe('adoptPendingWrites', () => {
259
+ const now = 100_000;
260
+ const row = (id: string, flushBy: number) => ({ id: `_00_pending_writes:${id}`, recordId: rid, tableName: 'thing', data: { a: 1 }, beforeRecord: { a: 0 }, flushBy });
261
+ const held = R.mergePendingWrite({ key: 'k', table: 'thing', recordId: 'thing:1', data: {}, before: null, firstAt: 0, mirrorId: '_00_pending_writes:mine' })(buildState());
262
+
263
+ it('moves overdue rows nobody here holds into the outbox, re-checks rows that may still be live', async () => {
264
+ const moved: any[] = [];
265
+ const out = await runPure(adoptPendingWrites(), {
266
+ state: held,
267
+ now,
268
+ handlers: {
269
+ 'local.query': (e: any) => {
270
+ expect(e.sql).toBe('SELECT * FROM _00_pending_writes');
271
+ return [[row('mine', 0), row('dead', now - 3_000), row('live', now + 500), row('other-live', now + 2_000), 'junk']];
272
+ },
273
+ 'local.execute': (e: any) => {
274
+ moved.push(e.vars);
275
+ },
276
+ },
277
+ });
278
+ expect(moved).toHaveLength(1);
279
+ expect(moved[0]).toMatchObject({ id: rid, table: 'thing', data: { a: 1 }, before: { a: 0 }, createdAt: now, wid: new RecordId('_00_pending_writes', 'dead') });
280
+ expect(out.state.outbox).toEqual([{ id: 'mutation-1', type: 'update', recordId: 'thing:1', table: 'thing', status: 'pending', ackedAt: null, attempts: 0 }]);
281
+ expect(out.dispatched).toEqual([{ type: 'Drain' }]);
282
+ expect(out.timers.get('adopt-writes')).toEqual({ ms: 3_500, event: { type: 'AdoptPendingWrites' } });
283
+ });
284
+
285
+ it('a follower nudges the leader instead; a failed move is logged and skipped; no table means nothing to do', async () => {
286
+ const follower = await runPure(adoptPendingWrites(), { state: { ...buildState(), tabRole: 'follower' }, now, handlers: { 'local.query': () => [[row('dead', 0)]] } });
287
+ expect(follower.log.some((e) => e.kind === 'local.execute')).toBe(false);
288
+ expect(follower.emitted).toEqual([{ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: '' } }]);
289
+ const quietFollower = await runPure(adoptPendingWrites(), { state: { ...held, tabRole: 'follower' }, now, handlers: { 'local.query': () => [[row('mine', 0)]] } });
290
+ expect(quietFollower.emitted).toEqual([]);
291
+ const failing = await runPure(adoptPendingWrites(), {
292
+ state: buildState(),
293
+ now,
294
+ handlers: {
295
+ 'local.query': () => [[row('dead', 0)]],
296
+ 'local.execute': () => {
297
+ throw new Error('closed');
298
+ },
299
+ },
300
+ });
301
+ expect(failing.state.outbox).toEqual([]);
302
+ expect(failing.dispatched).toEqual([]);
303
+ expect(failing.emitted).toContainEqual(expect.objectContaining({ type: 'log', level: 'warn' }));
304
+ const noTable = await runPure(adoptPendingWrites(), {
305
+ state: buildState(),
306
+ handlers: {
307
+ 'local.query': () => {
308
+ throw new Error('no table');
309
+ },
310
+ },
311
+ });
312
+ expect(noTable.dispatched).toEqual([]);
313
+ const empty = await runPure(adoptPendingWrites(), { state: buildState(), handlers: { 'local.query': () => [[]] } });
314
+ expect(empty.timers.size).toBe(0);
315
+ });
163
316
  });