@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.
@@ -15,6 +15,17 @@ import type { QueryStatus } from '../types';
15
15
  * behind `status: 'fetching'`, `notified` says whether subscribers received
16
16
  * at least one materialization this registration.
17
17
  *
18
+ * `answered` says subscribers have been handed the rows of a known membership
19
+ * (the server's, or the durable one of an earlier session) at least once:
20
+ * a materialization ran past `cold`, with no body of a server set still to
21
+ * download, and the membership it drew was still the one in state when it
22
+ * committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
23
+ * reconnect or a lost view does not send a painted query back to loading.
24
+ * It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
25
+ * a `cached` seed or a freshly applied server set alone is not enough,
26
+ * because `records` is still empty (or stale) until `materialize` paints, and
27
+ * "known and empty" read in that beat made apps create rows that existed.
28
+ *
18
29
  * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
19
30
  * query already shows: a registration of a query that is not `cold`. It still
20
31
  * holds `status: 'fetching'` (results are not yet confirmed), but it is not
@@ -29,6 +40,7 @@ export interface QueryLifecycle {
29
40
  readonly fetchDepth: number;
30
41
  readonly backgroundDepth: number;
31
42
  readonly notified: boolean;
43
+ readonly answered: boolean;
32
44
  }
33
45
 
34
46
  export type LifecycleEvent =
@@ -41,7 +53,7 @@ export type LifecycleEvent =
41
53
  | { type: 'remote-dropped' }
42
54
  | { type: 'fetch-begin'; background?: boolean }
43
55
  | { type: 'fetch-end'; background?: boolean }
44
- | { type: 'notified' }
56
+ | { type: 'notified'; answered?: boolean }
45
57
  | { type: 'bucket-switch'; resolvedBefore: boolean };
46
58
 
47
59
  export class LifecycleError extends Error {
@@ -52,7 +64,14 @@ export class LifecycleError extends Error {
52
64
  }
53
65
 
54
66
  export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
55
- return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false };
67
+ return {
68
+ phase: resolvedBefore ? 'cached' : 'cold',
69
+ remote: 'unregistered',
70
+ fetchDepth: 0,
71
+ backgroundDepth: 0,
72
+ notified: false,
73
+ answered: false,
74
+ };
56
75
  }
57
76
 
58
77
  /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
@@ -84,14 +103,23 @@ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycl
84
103
  const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
85
104
  return { ...l, fetchDepth, backgroundDepth };
86
105
  }
87
- case 'notified':
88
- return l.notified ? l : { ...l, notified: true };
106
+ case 'notified': {
107
+ // A cold render is a guess from the local store, never an answer.
108
+ const answered = l.answered || (ev.answered === true && l.phase !== 'cold');
109
+ return l.notified && answered === l.answered ? l : { ...l, notified: true, answered };
110
+ }
89
111
  case 'bucket-switch':
90
112
  return { ...seedLifecycle(ev.resolvedBefore), fetchDepth: 0 };
91
113
  }
92
114
  }
93
115
 
116
+ /** Membership is known (server set or durable seed); its rows may not be painted yet. */
94
117
  export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold';
118
+ /**
119
+ * Subscribers hold the rows of a known membership: what the authority channel
120
+ * (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
121
+ */
122
+ export const isAnswered = (l: QueryLifecycle): boolean => l.answered;
95
123
  export const hasServerMembership = (l: QueryLifecycle): boolean =>
96
124
  l.phase === 'live' || l.phase === 'view-lost';
97
125
  export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
@@ -79,6 +79,14 @@ describe('query reducers', () => {
79
79
  expect(s2.queries.get('a')!.telemetry.phaseSamples.localFetch).toEqual([12]);
80
80
  });
81
81
 
82
+ it('setRecords answers a query only when told to, and never a cold one', () => {
83
+ const cold = buildState([e('a')]);
84
+ expect(R.setRecords('a', [], false, null, true)(cold).queries.get('a')!.lifecycle.answered).toBe(false);
85
+ const cached = buildState([e('a', { lifecycle: { phase: 'cached' } })]);
86
+ expect(R.setRecords('a', [], false, null)(cached).queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: false });
87
+ expect(R.setRecords('a', [], false, null, true)(cached).queries.get('a')!.lifecycle).toMatchObject({ notified: true, answered: true });
88
+ });
89
+
82
90
  it('sample windows are capped', () => {
83
91
  let s = buildState([e('a')]);
84
92
  for (let i = 0; i < 105; i++) s = R.recordPhase('a', 'localFetch', i)(s);
@@ -200,6 +208,9 @@ describe('outbox', () => {
200
208
  const s3 = R.outboxBumpAttempts('m1')(s2);
201
209
  expect(s3.outbox[0].attempts).toBe(1);
202
210
  expect(R.outboxBumpAttempts('nope')(s3)).toBe(s3);
211
+ const doubt = R.outboxMarkInDoubt(['m1', 'nope'])(s3);
212
+ expect(doubt.outbox[0]).toMatchObject({ id: 'm1', inDoubt: true, attempts: 1 });
213
+ expect(R.outboxMarkInDoubt(['m1'])(doubt)).toBe(doubt);
203
214
  const s4 = R.outboxRemove('m1')(s3);
204
215
  expect(s4.outbox).toEqual([]);
205
216
  expect(s4.dirty.has('a')).toBe(true);
@@ -249,12 +260,14 @@ describe('bucket switch reducers', () => {
249
260
  R.outboxReplace([buildOutboxItem()])
250
261
  );
251
262
  const id = { table: '_00_query', id: 'new' } as any;
252
- const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
263
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false, answered: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
253
264
  const en = s1.queries.get('a')!;
254
265
  expect(en.def.id).toBe(id);
255
266
  expect(en.lifecycle.phase).toBe('cached');
256
267
  expect(en.remoteArray).toEqual([['t:2', 1]]);
257
- expect(en.records).toEqual([]);
268
+ // What subscribers still hold: the next render diffs against it, so a new
269
+ // store that renders `[]` is a change they hear about.
270
+ expect(en.records).toEqual([{ id: 't:1' }]);
258
271
  expect(en.serverState).toBeNull();
259
272
  expect(en.registerAttempts).toBe(0);
260
273
  expect(s1.dirty.has('a')).toBe(true);
@@ -153,8 +153,13 @@ export const setSubqueryRemoteArray =
153
153
  return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
154
154
  };
155
155
 
156
+ /**
157
+ * Commit a materialization. `answered`: it drew a known membership with no
158
+ * body still to download, and that membership is still current (see
159
+ * `QueryLifecycle`).
160
+ */
156
161
  export const setRecords =
157
- (hash: QueryHash, records: ReadonlyArray<Row>, changed: boolean, materializeMs: number | null): Reducer =>
162
+ (hash: QueryHash, records: ReadonlyArray<Row>, changed: boolean, materializeMs: number | null, answered = false): Reducer =>
158
163
  (s) => {
159
164
  const next = withEntry(s, hash, (e) => {
160
165
  const prev = e.telemetry.phaseSamples.localFetch ?? [];
@@ -171,7 +176,7 @@ export const setRecords =
171
176
  ...e,
172
177
  records: changed ? records : e.records,
173
178
  telemetry,
174
- lifecycle: transition(e.lifecycle, { type: 'notified' }),
179
+ lifecycle: transition(e.lifecycle, { type: 'notified', answered }),
175
180
  };
176
181
  });
177
182
  return next === s ? s : { ...next, dirty: withoutAll(next.dirty, [hash]) };
@@ -403,6 +408,15 @@ export const outboxBumpAttempts =
403
408
  return { ...s, outbox };
404
409
  };
405
410
 
411
+ /** Mark items whose last push may have committed although no answer came back. */
412
+ export const outboxMarkInDoubt =
413
+ (ids: readonly string[]): Reducer =>
414
+ (s) => {
415
+ const marked = new Set(ids);
416
+ if (!s.outbox.some((i) => marked.has(i.id) && !i.inDoubt)) return s;
417
+ return { ...s, outbox: s.outbox.map((i) => (marked.has(i.id) && !i.inDoubt ? { ...i, inDoubt: true } : i)) };
418
+ };
419
+
406
420
  /** Drop acked items older than `graceMs` (membership never named them). */
407
421
  export const outboxPruneAcked =
408
422
  (now: number, graceMs: number): Reducer =>
@@ -454,7 +468,13 @@ export const setIdentity =
454
468
  ): Reducer =>
455
469
  (s) => ({ ...s, ...patch });
456
470
 
457
- /** Re-home a query after a bucket switch: new store, same hash, fresh sync state. */
471
+ /**
472
+ * Re-home a query after a bucket switch: new store, same hash, fresh sync state.
473
+ * `records` is kept: it is what subscribers were last handed, and the next
474
+ * render diffs against it. Blanking it here (without telling anyone) let a
475
+ * new store that renders `[]` read as unchanged, so the previous principal's
476
+ * rows stayed on screen under an authority flip that vouched for them.
477
+ */
458
478
  export const rebindQuery =
459
479
  (
460
480
  hash: QueryHash,
@@ -468,7 +488,6 @@ export const rebindQuery =
468
488
  remoteArray: next.remoteArray,
469
489
  localArray: next.localArray,
470
490
  subqueryRemoteArray: [],
471
- records: [],
472
491
  serverState: null,
473
492
  lastHeartbeatAt: null,
474
493
  lastPolledAt: null,
@@ -217,6 +217,7 @@ export function toQueryState(e: QueryEntry): QueryState {
217
217
  viewLost: l.phase === 'view-lost',
218
218
  serverState: e.serverState,
219
219
  syncNotified: l.notified,
220
+ answered: l.answered,
220
221
  ttlTimer: null,
221
222
  ttlDurationMs: e.def.ttlMs,
222
223
  lastHeartbeatAt: e.lastHeartbeatAt ?? undefined,
package/src/types.ts CHANGED
@@ -706,6 +706,12 @@ export interface QueryState {
706
706
  * always emits at least once even when its records are unchanged — otherwise
707
707
  * an empty re-registered window would never notify and stay "loading". */
708
708
  syncNotified?: boolean;
709
+ /**
710
+ * Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
711
+ * `answered`): what the authority channel and the bindings'
712
+ * `isAuthoritative()` report. Ephemeral.
713
+ */
714
+ answered?: boolean;
709
715
  /** Timer for TTL expiration. */
710
716
  ttlTimer: NodeJS.Timeout | null;
711
717
  /** TTL duration in milliseconds. */
@@ -798,9 +804,12 @@ export interface QueryTimings {
798
804
  export type QueryUpdateCallback = (records: Record<string, any>[]) => void;
799
805
  export type QueryStatusCallback = (status: QueryStatus) => void;
800
806
  /**
801
- * Observer for a query's authority flips: `true` once server membership is
802
- * known for it (registration, poll, durable seed), `false` when it is reset
803
- * (bucket switch). What the bindings' `isAuthoritative()` mirrors.
807
+ * Observer for a query's authority flips: `true` once subscribers hold the
808
+ * rendered rows of a known membership (a server answer whose bodies are
809
+ * local, or the durable membership of an earlier session), delivered after
810
+ * the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
811
+ * not enough: a `cached` re-seed or a just-applied server set has no rows
812
+ * painted yet. What the bindings' `isAuthoritative()` mirrors.
804
813
  */
805
814
  export type QueryAuthorityCallback = (known: boolean) => void;
806
815
  export type MutationCallback = (mutations: MutationEvent[]) => void;
@@ -39,10 +39,43 @@ describe('classifySyncError', () => {
39
39
  expect(classifySyncError(new Error(message))).toBe('network');
40
40
  });
41
41
 
42
+ // Verbatim from SurrealDB 3.1.5 and 3.3.4: the write was refused because
43
+ // sp00ky's own ingest event could not reach the scheduler / SSP. The outbox
44
+ // must keep these queued, never roll them back (see ingest-outage.integration.test.ts).
45
+ it.each([
46
+ // Refused connection and a post past the event TIMEOUT read the same.
47
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: error sending request for url (http://scheduler:9667/ingest)',
48
+ 'Error while processing event _00_item_delete: There was an error processing a remote HTTP request: error sending request for url (http://host.docker.internal:50466/ingest)',
49
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 500 Internal Server Error',
50
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 502 Bad Gateway',
51
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
52
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 408 Request Timeout',
53
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 429 Too Many Requests',
54
+ 'Error while processing event _00_item_mutation: failed to lookup address information: Name or service not known',
55
+ // An app event that wrote to a synced table carries the inner event.
56
+ 'Error while processing event app_fanout: Error while processing event _00_b_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
57
+ ])('classifies a failed sp00ky ingest post %j as network (retry, never rollback)', (message) => {
58
+ expect(classifySyncError(new Error(message))).toBe('network');
59
+ });
60
+
42
61
  it.each([
43
62
  'Permission denied',
63
+ // Verbatim from SurrealDB 3.3.4: a record user running a statement it may not.
64
+ 'IAM error: Not enough permissions to perform this action',
44
65
  'There was a problem with the database: record already exists',
45
66
  'Parse error: unexpected token',
67
+ "Couldn't coerce value for field `title` of `item:1`: Expected `string` but found `1`",
68
+ // An app's own event: its endpoint, its THROW, its business.
69
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: 500 Internal Server Error',
70
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: error sending request for url (http://crm.example/hook)',
71
+ 'Error while processing event cf_guard: An error occurred: conformance guard: write refused',
72
+ // A THROW inside a sp00ky event is not a failed post.
73
+ 'Error while processing event _00_item_mutation: An error occurred: inner',
74
+ // A 4xx from /ingest no retry fixes: payload rejected, too large, secret mismatch.
75
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 400 Bad Request',
76
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 401 Unauthorized',
77
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 413 Payload Too Large',
78
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 422 Unprocessable Entity',
46
79
  ])('classifies %j as application', (message) => {
47
80
  expect(classifySyncError(new Error(message))).toBe('application');
48
81
  });
@@ -39,10 +39,37 @@ const NETWORK_ERROR_PATTERNS = [
39
39
  'specify a database',
40
40
  ];
41
41
 
42
+ /**
43
+ * A write SurrealDB refused because sp00ky's OWN ingest post failed: every
44
+ * synced table's `_00_<table>_mutation` / `_00_<table>_delete` event posts to
45
+ * the scheduler or SSP inside the write's transaction (`sp00ky.rs::ingest_post`),
46
+ * so a scheduler that is down, slow past the event TIMEOUT, or answering 5xx
47
+ * aborts the write. That is an outage, not the mutation's fault, and it clears
48
+ * on its own; classified `application` it was rolled back and discarded, so
49
+ * every write queued during a scheduler restart or a cold SSP was lost.
50
+ *
51
+ * Wording on SurrealDB 3.1.5 and 3.3.4 (the event name may be nested inside an
52
+ * app event that wrote to a synced table):
53
+ * "Error while processing event _00_item_mutation: There was an error
54
+ * processing a remote HTTP request: error sending request for url (...)"
55
+ * (refused connection or TIMEOUT), "...: 503 Service Unavailable" (5xx),
56
+ * "Error while processing event _00_item_mutation: failed to lookup address
57
+ * information: ..." (DNS).
58
+ *
59
+ * Only `_00_` events count: an app's own event posting to its own endpoint
60
+ * stays `application`, as does a 4xx from `/ingest` other than 408/429 (a
61
+ * malformed or oversized payload, a secret mismatch), which no retry fixes and
62
+ * which would otherwise hold the outbox's serial lane forever.
63
+ */
64
+ const SP00KY_EVENT_POST_FAILED =
65
+ /error while processing event _00_[^:\s]*: (?:there was an error processing a remote http request: (?:error sending request|5\d\d\b|408\b|429\b)|failed to lookup address information)/;
66
+
42
67
  export function classifySyncError(error: unknown): 'network' | 'application' {
43
68
  const message =
44
69
  error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
45
70
 
71
+ if (SP00KY_EVENT_POST_FAILED.test(message)) return 'network';
72
+
46
73
  for (const pattern of NETWORK_ERROR_PATTERNS) {
47
74
  if (message.includes(pattern)) {
48
75
  return 'network';