@spooky-sync/core 0.0.1-canary.333 → 0.0.1-canary.335

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.
@@ -19,6 +19,7 @@ import {
19
19
  planRevert,
20
20
  remoteBatch,
21
21
  toOutboxItem,
22
+ BATCH_SKIPPED,
22
23
  FAILED_TABLE,
23
24
  type PendingMutationRow,
24
25
  } from './rows';
@@ -71,6 +72,8 @@ const ERR = (r: StatementResult | undefined): string | null => (r && r.status ==
71
72
  * A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
72
73
  * so a CREATE retried after its first attempt committed (and only the answer
73
74
  * was lost) finds it already there: that is a success, not a rejection.
75
+ * `remoteBatch` already makes such a CREATE a no-op on the server; this covers
76
+ * a row the user may create but not read back, where that check cannot see it.
74
77
  */
75
78
  const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, err: string): boolean =>
76
79
  row.mutationType === 'create' && row.tableName.endsWith('__crdt_log') && /already exists/i.test(err);
@@ -80,6 +83,17 @@ const isRepeatedLogCreate = (row: { mutationType: string; tableName: string }, e
80
83
  * per request, per-statement outcome. Accepted rows are acked (they stay in
81
84
  * the overlay until membership names them), rejected rows are rolled back,
82
85
  * a transport failure leaves the unsent tail queued behind a backoff timer.
86
+ * The server stops a batch at its first failed statement (`remoteBatch`), so
87
+ * the rows after a rejection are sent again at once, and a re-sent row behind
88
+ * a retryable failure never ran before, so it cannot read "already exists".
89
+ *
90
+ * A request that throws a transport error (the push timeout, a dropped
91
+ * socket) may still have committed on the server, and so may one sent by a
92
+ * session that died before its answer. Those rows are marked in doubt
93
+ * (`OutboxItem.inDoubt`, every row loaded from the store is), and their
94
+ * re-sent CREATE accepts the row already on the server when it holds what the
95
+ * CREATE would write, instead of failing "already exists" and rolling back a
96
+ * write the server kept.
83
97
  */
84
98
  export function* drain(env: SagaEnv): Saga<void> {
85
99
  for (;;) {
@@ -102,12 +116,12 @@ export function* drain(env: SagaEnv): Saga<void> {
102
116
  }
103
117
  if (rows.length === 0) continue;
104
118
 
105
- const req = remoteBatch(rows);
119
+ const req = remoteBatch(rows, new Set(batch.filter((i) => i.inDoubt).map((i) => i.id)));
106
120
  let results: StatementResult[];
107
121
  try {
108
122
  const raw = (yield fx.remote.query(req.sql, req.vars, env.pushTimeoutMs)) as unknown;
109
- // A response without statement results is a transport fault: nothing
110
- // ran, everything stays queued (handled as `!r` below).
123
+ // A response without statement results is a transport fault: every
124
+ // row stays queued, in doubt (handled as `!r` below).
111
125
  results = Array.isArray(raw) ? (raw as StatementResult[]) : [];
112
126
  } catch (error) {
113
127
  if (classifySyncError(error) === 'application') {
@@ -117,8 +131,9 @@ export function* drain(env: SagaEnv): Saga<void> {
117
131
  yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
118
132
  continue;
119
133
  }
134
+ // No answer: the server may have committed any prefix of the batch.
120
135
  const attempts = batch.find((i) => i.id === rows[0].id)!.attempts;
121
- yield fx.state.update(R.outboxBumpAttempts(rows[0].id));
136
+ yield fx.state.update(R.compose(R.outboxMarkInDoubt(rows.map((row) => row.id)), R.outboxBumpAttempts(rows[0].id)));
122
137
  yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
123
138
  yield fx.timer.set('outbox', backoffMs(attempts), { type: 'Drain' });
124
139
  return;
@@ -130,7 +145,9 @@ export function* drain(env: SagaEnv): Saga<void> {
130
145
  const row = rows[i];
131
146
  const r = results[i];
132
147
  if (!r) {
133
- // Statements after a transport-level cut: they never ran.
148
+ // Statements after a transport-level cut: no answer says whether
149
+ // they ran, so they are in doubt.
150
+ yield fx.state.update(R.outboxMarkInDoubt(rows.slice(i).map((unanswered) => unanswered.id)));
134
151
  cutAt = i;
135
152
  break;
136
153
  }
@@ -147,6 +164,9 @@ export function* drain(env: SagaEnv): Saga<void> {
147
164
  yield fx.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled', mutationId: row.id, recordId: row.recordId, eventType: row.mutationType } });
148
165
  continue;
149
166
  }
167
+ // An earlier statement in this batch was rejected (or was a repeated
168
+ // log row), so this one never ran: the next round sends it again.
169
+ if (err.includes(BATCH_SKIPPED)) break;
150
170
  if (classifySyncError(new Error(err)) === 'network') {
151
171
  cutAt = i;
152
172
  break;
@@ -60,6 +60,8 @@ describe('parsePendingRow', () => {
60
60
  status: 'pending',
61
61
  ackedAt: null,
62
62
  attempts: 0,
63
+ // A row read back from the store may have been sent by a session that died.
64
+ inDoubt: true,
63
65
  });
64
66
  expect(rows.loadPendingRows()).toBe('SELECT * FROM _00_pending_mutations ORDER BY id ASC');
65
67
  expect(rows.loadFailedRows()).toBe('SELECT * FROM _00_failed_mutations ORDER BY failedAt ASC');
@@ -143,7 +145,14 @@ describe('remoteBatch', () => {
143
145
  { id: 'm3', mutationType: 'delete', recordId: 'thing:3', tableName: 'thing', createdAt: 0, v: 2 },
144
146
  { id: 'm4', mutationType: 'update', recordId: 'thing:4', tableName: 'thing', createdAt: 0, v: 2 },
145
147
  ]);
146
- expect(sql).toBe('CREATE ONLY $id0 SET a = $d0_a;\nUPDATE $id1 MERGE $data1;\nDELETE $id2;\nUPDATE $id3 MERGE $data3');
148
+ // Chained: each statement runs only if the one before it committed.
149
+ const skip = `IF !$ok0 { THROW "${rows.BATCH_SKIPPED}" }; `;
150
+ expect(sql.split(';\n')).toEqual([
151
+ 'LET $ok0 = { CREATE ONLY $id0 SET a = $d0_a; true }',
152
+ `LET $ok1 = { ${skip}UPDATE $id1 MERGE $data1; true }`,
153
+ `LET $ok2 = { ${skip.replace('$ok0', '$ok1')}DELETE $id2; true }`,
154
+ `LET $ok3 = { ${skip.replace('$ok0', '$ok2')}UPDATE $id3 MERGE $data3; true }`,
155
+ ]);
147
156
  expect(vars).toEqual({
148
157
  id0: rid('thing', '1'),
149
158
  d0_a: 1,
@@ -153,7 +162,45 @@ describe('remoteBatch', () => {
153
162
  id3: rid('thing', '4'),
154
163
  data3: {},
155
164
  });
156
- expect(rows.remoteBatch([{ id: 'm', mutationType: 'create', recordId: 'thing:9', tableName: 'thing', createdAt: 0, v: 2 }]).sql).toBe('CREATE ONLY $id0 SET ');
165
+ expect(rows.remoteBatch([{ id: 'm', mutationType: 'create', recordId: 'thing:9', tableName: 'thing', createdAt: 0, v: 2 }]).sql).toBe('LET $ok0 = { CREATE ONLY $id0 SET ; true }');
166
+ });
167
+
168
+ it('an in-doubt CREATE is a no-op when the server row matches every field no later in-doubt row of it may have changed', () => {
169
+ const row = (id: string, mutationType: rows.PendingMutationRow['mutationType'], recordId: string, data?: Record<string, unknown>): rows.PendingMutationRow => ({
170
+ id,
171
+ mutationType,
172
+ recordId,
173
+ tableName: recordId.split(':')[0],
174
+ data,
175
+ createdAt: 0,
176
+ v: 2,
177
+ });
178
+ const was = (i: number, ...keys: string[]) =>
179
+ `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${[`($was${i} = NONE)`, ...keys.map((k) => `($was${i}.${k} != $d${i}_${k})`)].join(' OR ')} { CREATE ONLY $id${i} SET ${['a', 'b']
180
+ .map((k) => `${k} = $d${i}_${k}`)
181
+ .join(', ')} }`;
182
+ const statement = (sql: string, i: number) => sql.split(';\n')[i].replace(/^LET \$ok\d+ = \{ (IF !\$ok\d+ \{ THROW "[^"]+" \}; )?/, '').replace(/; true \}$/, '');
183
+ const batch = [
184
+ row('m1', 'create', 'thing:1', { a: 1, b: 1 }),
185
+ row('m2', 'update', 'thing:1', { b: 2 }),
186
+ row('m3', 'create', 'thing:2', { a: 1, b: 1 }),
187
+ row('m4', 'update', 'thing:2', { a: 2 }), // not in doubt: it never ran
188
+ row('m5', 'create', 'thing:3', { a: 1, b: 1 }),
189
+ row('m6', 'delete', 'thing:3'),
190
+ row('m7', 'create', 'thing:4', { a: 1, b: 1 }),
191
+ row('m8', 'update', 'thing:9', { a: 2 }), // another record
192
+ row('m9', 'update', 'thing:1'), // no payload: changes nothing
193
+ ];
194
+ const { sql } = rows.remoteBatch(batch, new Set(['m1', 'm2', 'm3', 'm5', 'm6', 'm8', 'm9']));
195
+ expect(statement(sql, 0)).toBe(was(0, 'a')); // b may be m2's
196
+ expect(statement(sql, 2)).toBe(was(2, 'a', 'b'));
197
+ expect(statement(sql, 4)).toBe(was(4)); // m6 may have deleted it and something else re-created it
198
+ expect(statement(sql, 6)).toBe('CREATE ONLY $id6 SET a = $d6_a, b = $d6_b'); // not in doubt: strict
199
+ // A CRDT log row's id is fresh and the row immutable: existence is proof, in doubt or not.
200
+ expect(statement(rows.remoteBatch([row('l', 'create', 'doc__crdt_log:1', { a: 1, b: 1 })]).sql, 0)).toBe(was(0));
201
+ expect(statement(rows.remoteBatch([row('e', 'create', 'thing:5')], new Set(['e'])).sql, 0)).toBe(
202
+ 'LET $was0 = (SELECT * FROM ONLY $id0); IF ($was0 = NONE) { CREATE ONLY $id0 SET }'
203
+ );
157
204
  });
158
205
  });
159
206
 
@@ -109,6 +109,11 @@ export function hydrateRowData(row: PendingMutationRow, columns: Record<string,
109
109
  }
110
110
  }
111
111
 
112
+ /**
113
+ * A row read back from the store at boot, bucket switch or leader takeover.
114
+ * The session that queued it may have sent it and died before the answer (a
115
+ * closed tab, a killed app, a lost leader), so its outcome is in doubt.
116
+ */
112
117
  export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
113
118
  id: row.id,
114
119
  type: row.mutationType,
@@ -117,6 +122,7 @@ export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
117
122
  status: 'pending',
118
123
  ackedAt: null,
119
124
  attempts: 0,
125
+ inDoubt: true,
120
126
  });
121
127
 
122
128
  /**
@@ -286,29 +292,95 @@ export interface RemoteBatch {
286
292
  }
287
293
 
288
294
  /**
289
- * Many outbox rows as ONE multi-statement request. Statement i answers row i.
290
- * Each statement is its own transaction on the server, as before.
295
+ * What a statement of a `remoteBatch` throws instead of running, because an
296
+ * earlier statement in the same request failed.
291
297
  */
292
- export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>): RemoteBatch {
293
- const vars: Vars = {};
294
- const stmts = rows.map((row, i) => {
295
- const rid = parseRecordIdString(row.recordId);
296
- vars[`id${i}`] = rid;
297
- switch (row.mutationType) {
298
- case 'create': {
299
- const data = row.data ?? {};
300
- const sets = Object.keys(data).map((key) => {
301
- vars[`d${i}_${key}`] = data[key];
302
- return `${key} = $d${i}_${key}`;
303
- });
304
- return `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
305
- }
306
- case 'update':
307
- vars[`data${i}`] = row.data ?? {};
308
- return `UPDATE $id${i} MERGE $data${i}`;
309
- case 'delete':
310
- return `DELETE $id${i}`;
298
+ export const BATCH_SKIPPED = 'sp00ky: not run, an earlier statement in this batch failed';
299
+
300
+ /** A CRDT edit-log row (`<table>__crdt_log`): written once under a fresh id, never changed. */
301
+ const isLogTable = (table: string): boolean => table.endsWith('__crdt_log');
302
+
303
+ /**
304
+ * Which fields of the row already on the server must equal the payload of the
305
+ * CREATE at `i` for that row to count as this CREATE's own earlier commit, or
306
+ * `null` when the CREATE must not tolerate an existing row at all.
307
+ *
308
+ * Only a CREATE whose outcome is in doubt tolerates one: elsewhere "already
309
+ * exists" means someone else holds the id, a rejection as before. The check
310
+ * is every payload field except those a later in-doubt row of the same
311
+ * record, which may have committed in the same lost round, could have changed
312
+ * since; those rows run again after it and set them anyway. A later in-doubt
313
+ * DELETE or CREATE of the record may have replaced the row, so then existence
314
+ * is all that is checked. Rows that are not in doubt never ran.
315
+ *
316
+ * A CRDT log row's id is fresh and the row immutable, so its existence is
317
+ * proof enough, in doubt or not.
318
+ */
319
+ function matchFields(rows: ReadonlyArray<PendingMutationRow>, i: number, inDoubt: ReadonlySet<string>): string[] | null {
320
+ const row = rows[i];
321
+ if (isLogTable(row.tableName)) return [];
322
+ if (!inDoubt.has(row.id)) return null;
323
+ const changed = new Set<string>();
324
+ for (const later of rows.slice(i + 1)) {
325
+ if (later.recordId !== row.recordId || !inDoubt.has(later.id)) continue;
326
+ if (later.mutationType !== 'update') return [];
327
+ for (const key of Object.keys(later.data ?? {})) changed.add(key);
328
+ }
329
+ return Object.keys(row.data ?? {}).filter((key) => !changed.has(key));
330
+ }
331
+
332
+ function remoteStatement(rows: ReadonlyArray<PendingMutationRow>, i: number, vars: Vars, inDoubt: ReadonlySet<string>): string {
333
+ const row = rows[i];
334
+ vars[`id${i}`] = parseRecordIdString(row.recordId);
335
+ switch (row.mutationType) {
336
+ case 'create': {
337
+ const data = row.data ?? {};
338
+ const sets = Object.keys(data).map((key) => {
339
+ vars[`d${i}_${key}`] = data[key];
340
+ return `${key} = $d${i}_${key}`;
341
+ });
342
+ const create = `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
343
+ const match = matchFields(rows, i, inDoubt);
344
+ if (!match) return create;
345
+ // The CREATE runs unless the row is there and matches; on a mismatch it
346
+ // fails with SurrealDB's own "already exists", a rejection as before.
347
+ const differs = [`($was${i} = NONE)`, ...match.map((key) => `($was${i}.${key} != $d${i}_${key})`)];
348
+ return `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${differs.join(' OR ')} { ${create} }`;
311
349
  }
350
+ case 'update':
351
+ vars[`data${i}`] = row.data ?? {};
352
+ return `UPDATE $id${i} MERGE $data${i}`;
353
+ case 'delete':
354
+ return `DELETE $id${i}`;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Many outbox rows as ONE multi-statement request. Statement i answers row i
360
+ * and is its own transaction on the server, as before, but the statements are
361
+ * chained: statement i binds `$ok<i>` only when it commits, and statement i+1
362
+ * throws `BATCH_SKIPPED` without touching anything unless `$ok<i>` is bound
363
+ * (a LET lives for one request only). So the server stops at the first
364
+ * failure: everything before it is committed, nothing after it ran.
365
+ *
366
+ * Without the chain, statements after a failed one ran anyway. A retryable
367
+ * failure at i left the rows after i queued although they had committed; the
368
+ * re-sent CREATE then read "already exists", was taken for a rejection and
369
+ * rolled back locally while the server kept the row. Acking them instead
370
+ * would have let a later write land before an earlier one it may depend on.
371
+ *
372
+ * The chain cannot help when the answer itself is lost: the request may have
373
+ * committed any prefix of the batch. Rows named in `inDoubt` (mutation ids)
374
+ * are such rows. An UPDATE MERGE or a DELETE is safe to run twice, a CREATE
375
+ * is not, so an in-doubt CREATE checks the row already there first (see
376
+ * `matchFields`) and is a no-op when it is its own: the batch goes on in
377
+ * order, with no "already exists" to cut it.
378
+ */
379
+ export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>, inDoubt: ReadonlySet<string> = new Set()): RemoteBatch {
380
+ const vars: Vars = {};
381
+ const stmts = rows.map((_, i) => {
382
+ const guard = i === 0 ? '' : `IF !$ok${i - 1} { THROW "${BATCH_SKIPPED}" }; `;
383
+ return `LET $ok${i} = { ${guard}${remoteStatement(rows, i, vars, inDoubt)}; true }`;
312
384
  });
313
385
  return { sql: stmts.join(';\n'), vars };
314
386
  }
@@ -97,6 +97,79 @@ describe('materialize', () => {
97
97
  });
98
98
  });
99
99
 
100
+ describe('materialize answers the query', () => {
101
+ const plan = { table: 'thing' } as any;
102
+ const byIds = { 'local.select': (e: any) => (e.plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })) };
103
+ const authority = (out: { emitted: Array<{ type: string }> }) => out.emitted.filter((e) => e.type === 'query:authority');
104
+
105
+ it('a cached re-seed: rows first, then authority; the next render flips nothing', async () => {
106
+ // The bucket-switch window: a durable view was found, `records` is not
107
+ // painted yet. Authority comes from this render, after the rows.
108
+ const s = R.markDirty(['a'])(
109
+ buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:1', 1]] })])
110
+ );
111
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
112
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cached', notified: true, answered: true });
113
+ expect(out.emitted).toEqual([
114
+ { type: 'query:records', hash: 'a', records: [{ id: 'thing:1' }] },
115
+ { type: 'query:authority', hash: 'a', known: true },
116
+ ]);
117
+ const again = await runPure(materialize('a'), { state: R.markDirty(['a'])(out.state), handlers: byIds });
118
+ expect(again.emitted).toEqual([]);
119
+ });
120
+
121
+ it('a known empty set answers without a rows emission', async () => {
122
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [] })]);
123
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
124
+ expect(out.emitted).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
125
+ });
126
+
127
+ it('a cold render never answers', async () => {
128
+ const s = buildState([buildEntry({ def: { hash: 'a', plan } })], R.reloadViews(new Map([['v', ['thing:1']]])));
129
+ const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': () => [{ id: 'thing:1' }] } });
130
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: false });
131
+ expect(authority(out)).toEqual([]);
132
+ });
133
+
134
+ it('a server set whose bodies are still downloading is not an answer yet', async () => {
135
+ const s = buildState(
136
+ [buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:1', 1], ['thing:2', 1]] })],
137
+ R.setVersions([['thing:1', 1]])
138
+ );
139
+ const out = await runPure(materialize('a'), { state: s, handlers: byIds });
140
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
141
+ expect(authority(out)).toEqual([]);
142
+ const landed = await runPure(materialize('a'), { state: R.setVersions([['thing:2', 1]])(out.state), handlers: byIds });
143
+ expect(landed.state.queries.get('a')!.lifecycle.answered).toBe(true);
144
+ expect(authority(landed)).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
145
+ });
146
+
147
+ it('a membership replaced while the read was out does not answer: the next render does', async () => {
148
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:1', 1]] })]);
149
+ const out = await runPure(materialize('a'), {
150
+ state: s,
151
+ handlers: {
152
+ 'local.select': (e: any, ctx) => {
153
+ ctx.state = R.rebindQuery('a', { id: new RecordId('_00_query', 'b'), lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [['thing:2', 1]], localArray: [] })(ctx.state);
154
+ return byIds['local.select'](e);
155
+ },
156
+ },
157
+ });
158
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
159
+ expect(authority(out)).toEqual([]);
160
+ });
161
+
162
+ it('after a rebind, a new store that renders nothing clears what the old principal painted', async () => {
163
+ const s = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live', answered: true }, remoteArray: [['thing:1', 1]], records: [{ id: 'thing:1' }] })]);
164
+ const rebound = R.rebindQuery('a', { id: new RecordId('_00_query', 'b'), lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [], localArray: [] })(s);
165
+ const out = await runPure(materialize('a'), { state: rebound, handlers: byIds });
166
+ expect(out.emitted).toEqual([
167
+ { type: 'query:records', hash: 'a', records: [] },
168
+ { type: 'query:authority', hash: 'a', known: true },
169
+ ]);
170
+ });
171
+ });
172
+
100
173
  describe('streamUpdate', () => {
101
174
  it('takes the local id-set (dirtying the query) and records ingest timings; unknown hashes are ignored', async () => {
102
175
  const s = buildState([buildEntry({ def: { hash: 'a' } })]);
@@ -4,7 +4,8 @@ import type { Saga } from '../kernel/saga';
4
4
  import { fx } from '../kernel/effects';
5
5
  import type { QueryEntry } from '../state/client-state';
6
6
  import * as R from '../state/reducers';
7
- import { overlay, vouched } from '../state/selectors';
7
+ import { needed, overlay, vouched } from '../state/selectors';
8
+ import { isAnswered } from '../state/lifecycle';
8
9
  import type { Overlay } from '../state/selectors';
9
10
  import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
10
11
  import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
@@ -17,12 +18,15 @@ const NONE: ReadonlySet<string> = new Set();
17
18
  * only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
18
19
  * query is dirty. Before the server has answered (`cold`) only bodies some
19
20
  * durable view vouches for paint (see `paintsCold`).
21
+ *
22
+ * Also the only place a query becomes answered (see `QueryLifecycle`): the
23
+ * authority flip is emitted here, after the rows it vouches for.
20
24
  */
21
25
  export function* materialize(hash: QueryHash): Saga<void> {
22
- const [entry, ov, known] = (yield fx.state.read((s) => {
26
+ const [entry, ov, known, complete] = (yield fx.state.read((s) => {
23
27
  const e = s.queries.get(hash);
24
- return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE];
25
- })) as [QueryEntry | undefined, Overlay, ReadonlySet<string>];
28
+ return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE, needed(s, hash).length === 0];
29
+ })) as [QueryEntry | undefined, Overlay, ReadonlySet<string>, boolean];
26
30
  if (!entry) return;
27
31
  const t0 = (yield fx.now()) as number;
28
32
  const isWindow = isWindowed(entry.def.surql);
@@ -54,10 +58,18 @@ export function* materialize(hash: QueryHash): Saga<void> {
54
58
  if (!current) return;
55
59
  const t1 = (yield fx.now()) as number;
56
60
  const changed = !rowsEqual(rows, current.records);
61
+ // These rows answer the query when they drew a known membership with no
62
+ // body of a server set still to download, and that membership is still
63
+ // the one in state. A server set applied, or a bucket switch, while the
64
+ // read was out dirtied the query again: the next render answers instead.
65
+ const answered =
66
+ complete && entry.lifecycle.phase !== 'cold' && current.lifecycle.phase !== 'cold' && current.remoteArray === entry.remoteArray;
57
67
  yield fx.state.update(
58
- R.compose(R.setRecords(hash, rows, changed, t1 - t0), changed ? R.stampUpdated(hash, t1) : (s) => s)
68
+ R.compose(R.setRecords(hash, rows, changed, t1 - t0, answered), changed ? R.stampUpdated(hash, t1) : (s) => s)
59
69
  );
60
70
  if (changed) yield fx.emit({ type: 'query:records', hash, records: rows });
71
+ // After the rows: a subscriber told "known" must already hold them.
72
+ if (answered && !isAnswered(current.lifecycle)) yield fx.emit({ type: 'query:authority', hash, known: true });
61
73
  }
62
74
 
63
75
  /**
@@ -72,7 +72,7 @@ describe('applyMembership', () => {
72
72
  expect(out.result).toBe('ignored');
73
73
  expect(out.state.queries.get('a')!).toMatchObject({ viewLostCount: 0, viewLostRetryAt: null });
74
74
  });
75
- it('applied: commits, writes the view row, flips authority once, releases acked items, asks for bodies', async () => {
75
+ it('applied: commits, writes the view row, releases acked items, asks for bodies; authority waits for the render', async () => {
76
76
  const s = buildState(
77
77
  [buildEntry({ def: { hash: 'a', viewKey: 'vk' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })],
78
78
  R.outboxReplace([buildOutboxItem({ id: 'm', recordId: 't:1', status: 'acked', ackedAt: 1 })])
@@ -81,7 +81,10 @@ describe('applyMembership', () => {
81
81
  expect(out.result).toBe('applied');
82
82
  expect(out.state.queries.get('a')!.lifecycle.phase).toBe('live');
83
83
  expect(out.state.outbox).toEqual([]);
84
- expect(out.emitted).toEqual([{ type: 'query:authority', hash: 'a', known: true }]);
84
+ // The set is known, but no row of it is painted: `materialize` flips authority.
85
+ expect(out.emitted).toEqual([]);
86
+ expect(out.state.queries.get('a')!.lifecycle.answered).toBe(false);
87
+ expect(out.state.dirty.has('a')).toBe(true);
85
88
  expect(out.log.find((e) => e.kind === 'local.upsert')).toEqual({
86
89
  kind: 'local.upsert',
87
90
  table: '_00_view',
@@ -10,7 +10,6 @@ import {
10
10
  backoffMs,
11
11
  } from '../kernel/constants';
12
12
  import type { ClientState, QueryEntry } from '../state/client-state';
13
- import { isAuthoritative } from '../state/lifecycle';
14
13
  import * as R from '../state/reducers';
15
14
  import { encodeRecordId } from '../utils/index';
16
15
  import { departedIds } from '../state/selectors';
@@ -88,10 +87,10 @@ export function* applyMembership(
88
87
  yield fx.timer.set(`view-lost:${hash}`, delay, { type: 'RecoverLostView', hash });
89
88
  return outcome;
90
89
  }
91
- const wasAuthoritative = isAuthoritative(entry.lifecycle);
92
90
  yield fx.state.update(R.commitMembership(hash, remoteArray, meta?.present ?? true));
93
91
  yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
94
- if (!wasAuthoritative) yield fx.emit({ type: 'query:authority', hash, known: true });
92
+ // No authority flip here: the set is known but its rows are not painted
93
+ // yet (bodies may still be on their way). `materialize` flips it once they are.
95
94
  const now = (yield fx.now()) as number;
96
95
  try {
97
96
  yield fx.local.upsert(
@@ -154,7 +154,8 @@ describe('registerRemote', () => {
154
154
  expect(req.sql.startsWith('fn::query::register($config);')).toBe(true);
155
155
  expect(req.vars.config).toMatchObject({ surql: entry.def.surql, ttl: '10m' });
156
156
  expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
- expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
157
+ // Authority follows the render of these rows, not the registration.
158
+ expect(out.emitted.some((ev) => ev.type === 'query:authority')).toBe(false);
158
159
  });
159
160
  it('a cold registration is foreground; a cached or live one is background', async () => {
160
161
  for (const [phase, background] of [['cold', 0], ['cached', 1], ['live', 1]] as const) {
@@ -79,6 +79,14 @@ export interface OutboxItem {
79
79
  readonly status: OutboxStatus;
80
80
  readonly ackedAt: number | null;
81
81
  readonly attempts: number;
82
+ /**
83
+ * A request carrying this row may have committed without the answer
84
+ * reaching this client: it threw a transport error, or the row was loaded
85
+ * from the store, so a session that died mid-push may have sent it. Its
86
+ * CREATE then accepts the row already on the server when that row holds
87
+ * what it would write (see `remoteBatch`). Absent means false.
88
+ */
89
+ readonly inDoubt?: boolean;
82
90
  }
83
91
 
84
92
  export type TabRole = 'solo' | 'leader' | 'follower';
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
3
- import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAnswered, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
4
4
 
5
5
  const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
6
6
  phase,
@@ -8,6 +8,7 @@ const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecyc
8
8
  fetchDepth: 0,
9
9
  backgroundDepth: 0,
10
10
  notified: false,
11
+ answered: false,
11
12
  ...over,
12
13
  });
13
14
 
@@ -33,8 +34,48 @@ describe('transition table (phase axis)', () => {
33
34
  expect(transition(at('view-lost'), { type: 'row-missing' }).phase).toBe('view-lost');
34
35
  });
35
36
  it('bucket-switch reseeds and clears fetch depth', () => {
36
- const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
37
- expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false });
37
+ const l = transition(at('live', { fetchDepth: 3, notified: true, answered: true }), { type: 'bucket-switch', resolvedBefore: true });
38
+ expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false });
39
+ });
40
+ });
41
+
42
+ describe('answered', () => {
43
+ it('a seed is never answered, not even a cached one: its rows are not painted yet', () => {
44
+ expect(seedLifecycle(true)).toMatchObject({ phase: 'cached', answered: false });
45
+ expect(isAnswered(seedLifecycle(true))).toBe(false);
46
+ expect(isAuthoritative(seedLifecycle(true))).toBe(true);
47
+ });
48
+ it('only a render of a known membership answers; a cold render is a guess', () => {
49
+ expect(transition(at('cold'), { type: 'notified', answered: true }).answered).toBe(false);
50
+ expect(transition(at('cached'), { type: 'notified' }).answered).toBe(false);
51
+ expect(transition(at('cached'), { type: 'notified', answered: false })).toMatchObject({ notified: true, answered: false });
52
+ for (const p of ['cached', 'live', 'view-lost'] as const) {
53
+ expect(transition(at(p), { type: 'notified', answered: true })).toMatchObject({ notified: true, answered: true });
54
+ }
55
+ });
56
+ it('latched through membership, a reconnect and a lost view; cleared by a re-seed', () => {
57
+ let l = transition(at('cached'), { type: 'notified', answered: true });
58
+ l = transition(l, { type: 'membership-applied', present: true });
59
+ expect(l).toMatchObject({ phase: 'live', answered: true });
60
+ l = transition(l, { type: 'notified', answered: false });
61
+ expect(l.answered).toBe(true);
62
+ l = transition(l, { type: 'remote-dropped' });
63
+ expect(l).toMatchObject({ notified: false, answered: true });
64
+ l = transition(l, { type: 'row-missing' });
65
+ expect(l).toMatchObject({ phase: 'view-lost', answered: true });
66
+ expect(transition(l, { type: 'bucket-switch', resolvedBefore: true }).answered).toBe(false);
67
+ expect(transition(l, { type: 'seed', resolvedBefore: true }).answered).toBe(false);
68
+ });
69
+ it('a server set applied to a cold query is not an answer until its render', () => {
70
+ const l = transition(at('cold'), { type: 'membership-applied', present: true });
71
+ expect(l).toMatchObject({ phase: 'live', answered: false });
72
+ expect(isAuthoritative(l)).toBe(true);
73
+ expect(isAnswered(l)).toBe(false);
74
+ });
75
+ it('an unchanged notified keeps identity', () => {
76
+ const l = transition(at('live'), { type: 'notified', answered: true });
77
+ expect(transition(l, { type: 'notified', answered: true })).toBe(l);
78
+ expect(transition(l, { type: 'notified' })).toBe(l);
38
79
  });
39
80
  });
40
81
 
@@ -111,6 +152,7 @@ describe('invariants over random event sequences', () => {
111
152
  { type: 'fetch-begin', background: true },
112
153
  { type: 'fetch-end', background: true },
113
154
  { type: 'notified' },
155
+ { type: 'notified', answered: true },
114
156
  { type: 'bucket-switch', resolvedBefore: false },
115
157
  { type: 'seed', resolvedBefore: true },
116
158
  ];
@@ -135,6 +177,7 @@ describe('invariants over random event sequences', () => {
135
177
  expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
136
178
  if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
137
179
  if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
180
+ if (l.answered) expect(l.phase).not.toBe('cold');
138
181
  }
139
182
  }
140
183
  });