@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.
- package/AGENTS.md +8 -2
- package/README.md +4 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.js +191 -47
- package/dist/types.d.ts +12 -3
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +6 -1
- package/src/boot/bucket-switch.saga.test.ts +9 -7
- package/src/boot/bucket-switch.saga.ts +6 -3
- package/src/client/runtime.test.ts +40 -0
- package/src/client/runtime.ts +2 -2
- package/src/modules/devtools/index.ts +3 -1
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/push.saga.test.ts +74 -14
- package/src/mutation/push.saga.ts +25 -5
- package/src/mutation/rows.test.ts +49 -2
- package/src/mutation/rows.ts +93 -21
- package/src/query/materialize.saga.test.ts +73 -0
- package/src/query/materialize.saga.ts +17 -5
- package/src/query/membership.saga.test.ts +5 -2
- package/src/query/membership.saga.ts +2 -3
- package/src/query/register.saga.test.ts +2 -1
- package/src/state/client-state.ts +8 -0
- package/src/state/lifecycle.test.ts +46 -3
- package/src/state/lifecycle.ts +32 -4
- package/src/state/reducers.test.ts +15 -2
- package/src/state/reducers.ts +23 -4
- package/src/state/selectors.ts +1 -0
- package/src/types.ts +12 -3
- package/src/utils/error-classification.test.ts +33 -0
- package/src/utils/error-classification.ts +27 -0
package/src/state/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/state/reducers.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
package/src/state/selectors.ts
CHANGED
|
@@ -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
|
|
802
|
-
*
|
|
803
|
-
*
|
|
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';
|