@spooky-sync/core 0.0.1-canary.333 → 0.0.1-canary.334
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/dist/index.d.ts +12 -0
- package/dist/index.js +54 -25
- package/dist/types.d.ts +12 -3
- package/package.json +3 -3
- 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/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/lifecycle.test.ts +46 -3
- package/src/state/lifecycle.ts +32 -4
- package/src/state/reducers.test.ts +12 -2
- package/src/state/reducers.ts +14 -4
- package/src/state/selectors.ts +1 -0
- package/src/types.ts +12 -3
package/dist/index.d.ts
CHANGED
|
@@ -1055,6 +1055,17 @@ type OutEvent = {
|
|
|
1055
1055
|
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
1056
1056
|
* at least one materialization this registration.
|
|
1057
1057
|
*
|
|
1058
|
+
* `answered` says subscribers have been handed the rows of a known membership
|
|
1059
|
+
* (the server's, or the durable one of an earlier session) at least once:
|
|
1060
|
+
* a materialization ran past `cold`, with no body of a server set still to
|
|
1061
|
+
* download, and the membership it drew was still the one in state when it
|
|
1062
|
+
* committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
|
|
1063
|
+
* reconnect or a lost view does not send a painted query back to loading.
|
|
1064
|
+
* It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
|
|
1065
|
+
* a `cached` seed or a freshly applied server set alone is not enough,
|
|
1066
|
+
* because `records` is still empty (or stale) until `materialize` paints, and
|
|
1067
|
+
* "known and empty" read in that beat made apps create rows that existed.
|
|
1068
|
+
*
|
|
1058
1069
|
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
1059
1070
|
* query already shows: a registration of a query that is not `cold`. It still
|
|
1060
1071
|
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
@@ -1068,6 +1079,7 @@ interface QueryLifecycle {
|
|
|
1068
1079
|
readonly fetchDepth: number;
|
|
1069
1080
|
readonly backgroundDepth: number;
|
|
1070
1081
|
readonly notified: boolean;
|
|
1082
|
+
readonly answered: boolean;
|
|
1071
1083
|
}
|
|
1072
1084
|
//#endregion
|
|
1073
1085
|
//#region src/kernel/effects.d.ts
|
package/dist/index.js
CHANGED
|
@@ -2198,8 +2198,8 @@ function trimHistory(history, cap) {
|
|
|
2198
2198
|
|
|
2199
2199
|
//#endregion
|
|
2200
2200
|
//#region src/modules/devtools/index.ts
|
|
2201
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2202
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2201
|
+
const CORE_VERSION = "0.0.1-canary.334";
|
|
2202
|
+
const WASM_VERSION = "0.0.1-canary.334";
|
|
2203
2203
|
const SURREAL_VERSION = "3.1.0";
|
|
2204
2204
|
var DevToolsService = class DevToolsService {
|
|
2205
2205
|
version = CORE_VERSION;
|
|
@@ -2338,7 +2338,7 @@ var DevToolsService = class DevToolsService {
|
|
|
2338
2338
|
remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
|
|
2339
2339
|
idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
|
|
2340
2340
|
membershipKnown: q.config.membershipKnown === true,
|
|
2341
|
-
authoritative: q.
|
|
2341
|
+
authoritative: q.answered === true,
|
|
2342
2342
|
viewLost: q.viewLost === true,
|
|
2343
2343
|
serverState: q.serverState ?? null,
|
|
2344
2344
|
timings: this.dataManager.phaseTimings(q)
|
|
@@ -2931,7 +2931,8 @@ function seedLifecycle(resolvedBefore) {
|
|
|
2931
2931
|
remote: "unregistered",
|
|
2932
2932
|
fetchDepth: 0,
|
|
2933
2933
|
backgroundDepth: 0,
|
|
2934
|
-
notified: false
|
|
2934
|
+
notified: false,
|
|
2935
|
+
answered: false
|
|
2935
2936
|
};
|
|
2936
2937
|
}
|
|
2937
2938
|
/** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
|
|
@@ -2980,17 +2981,27 @@ function transition(l, ev) {
|
|
|
2980
2981
|
backgroundDepth
|
|
2981
2982
|
};
|
|
2982
2983
|
}
|
|
2983
|
-
case "notified":
|
|
2984
|
-
|
|
2985
|
-
notified:
|
|
2986
|
-
|
|
2984
|
+
case "notified": {
|
|
2985
|
+
const answered = l.answered || ev.answered === true && l.phase !== "cold";
|
|
2986
|
+
return l.notified && answered === l.answered ? l : {
|
|
2987
|
+
...l,
|
|
2988
|
+
notified: true,
|
|
2989
|
+
answered
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2987
2992
|
case "bucket-switch": return {
|
|
2988
2993
|
...seedLifecycle(ev.resolvedBefore),
|
|
2989
2994
|
fetchDepth: 0
|
|
2990
2995
|
};
|
|
2991
2996
|
}
|
|
2992
2997
|
}
|
|
2998
|
+
/** Membership is known (server set or durable seed); its rows may not be painted yet. */
|
|
2993
2999
|
const isAuthoritative = (l) => l.phase !== "cold";
|
|
3000
|
+
/**
|
|
3001
|
+
* Subscribers hold the rows of a known membership: what the authority channel
|
|
3002
|
+
* (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
|
|
3003
|
+
*/
|
|
3004
|
+
const isAnswered = (l) => l.answered;
|
|
2994
3005
|
const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
|
|
2995
3006
|
const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
|
|
2996
3007
|
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
@@ -3208,7 +3219,12 @@ const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => {
|
|
|
3208
3219
|
if (!entry || entry.lifecycle.phase === "cold") return next;
|
|
3209
3220
|
return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
|
|
3210
3221
|
};
|
|
3211
|
-
|
|
3222
|
+
/**
|
|
3223
|
+
* Commit a materialization. `answered`: it drew a known membership with no
|
|
3224
|
+
* body still to download, and that membership is still current (see
|
|
3225
|
+
* `QueryLifecycle`).
|
|
3226
|
+
*/
|
|
3227
|
+
const setRecords = (hash, records, changed, materializeMs, answered = false) => (s) => {
|
|
3212
3228
|
const next = withEntry(s, hash, (e) => {
|
|
3213
3229
|
const prev = e.telemetry.phaseSamples.localFetch ?? [];
|
|
3214
3230
|
const telemetry = {
|
|
@@ -3227,7 +3243,10 @@ const setRecords = (hash, records, changed, materializeMs) => (s) => {
|
|
|
3227
3243
|
...e,
|
|
3228
3244
|
records: changed ? records : e.records,
|
|
3229
3245
|
telemetry,
|
|
3230
|
-
lifecycle: transition(e.lifecycle, {
|
|
3246
|
+
lifecycle: transition(e.lifecycle, {
|
|
3247
|
+
type: "notified",
|
|
3248
|
+
answered
|
|
3249
|
+
})
|
|
3231
3250
|
};
|
|
3232
3251
|
});
|
|
3233
3252
|
return next === s ? s : {
|
|
@@ -3484,7 +3503,13 @@ const setIdentity = (patch) => (s) => ({
|
|
|
3484
3503
|
...s,
|
|
3485
3504
|
...patch
|
|
3486
3505
|
});
|
|
3487
|
-
/**
|
|
3506
|
+
/**
|
|
3507
|
+
* Re-home a query after a bucket switch: new store, same hash, fresh sync state.
|
|
3508
|
+
* `records` is kept: it is what subscribers were last handed, and the next
|
|
3509
|
+
* render diffs against it. Blanking it here (without telling anyone) let a
|
|
3510
|
+
* new store that renders `[]` read as unchanged, so the previous principal's
|
|
3511
|
+
* rows stayed on screen under an authority flip that vouched for them.
|
|
3512
|
+
*/
|
|
3488
3513
|
const rebindQuery = (hash, next) => (s) => {
|
|
3489
3514
|
const rebound = withEntry(s, hash, (e) => ({
|
|
3490
3515
|
...e,
|
|
@@ -3496,7 +3521,6 @@ const rebindQuery = (hash, next) => (s) => {
|
|
|
3496
3521
|
remoteArray: next.remoteArray,
|
|
3497
3522
|
localArray: next.localArray,
|
|
3498
3523
|
subqueryRemoteArray: [],
|
|
3499
|
-
records: [],
|
|
3500
3524
|
serverState: null,
|
|
3501
3525
|
lastHeartbeatAt: null,
|
|
3502
3526
|
lastPolledAt: null,
|
|
@@ -3729,6 +3753,7 @@ function toQueryState(e) {
|
|
|
3729
3753
|
viewLost: l.phase === "view-lost",
|
|
3730
3754
|
serverState: e.serverState,
|
|
3731
3755
|
syncNotified: l.notified,
|
|
3756
|
+
answered: l.answered,
|
|
3732
3757
|
ttlTimer: null,
|
|
3733
3758
|
ttlDurationMs: e.def.ttlMs,
|
|
3734
3759
|
lastHeartbeatAt: e.lastHeartbeatAt ?? void 0,
|
|
@@ -4820,14 +4845,8 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
|
|
|
4820
4845
|
});
|
|
4821
4846
|
return outcome;
|
|
4822
4847
|
}
|
|
4823
|
-
const wasAuthoritative = isAuthoritative(entry.lifecycle);
|
|
4824
4848
|
yield fx.state.update(commitMembership(hash, remoteArray, meta?.present ?? true));
|
|
4825
4849
|
yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
|
|
4826
|
-
if (!wasAuthoritative) yield fx.emit({
|
|
4827
|
-
type: "query:authority",
|
|
4828
|
-
hash,
|
|
4829
|
-
known: true
|
|
4830
|
-
});
|
|
4831
4850
|
const now = yield fx.now();
|
|
4832
4851
|
try {
|
|
4833
4852
|
yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, entry.subqueryRemoteArray, true, now), "replace");
|
|
@@ -6671,14 +6690,18 @@ const NONE = /* @__PURE__ */ new Set();
|
|
|
6671
6690
|
* only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
|
|
6672
6691
|
* query is dirty. Before the server has answered (`cold`) only bodies some
|
|
6673
6692
|
* durable view vouches for paint (see `paintsCold`).
|
|
6693
|
+
*
|
|
6694
|
+
* Also the only place a query becomes answered (see `QueryLifecycle`): the
|
|
6695
|
+
* authority flip is emitted here, after the rows it vouches for.
|
|
6674
6696
|
*/
|
|
6675
6697
|
function* materialize(hash) {
|
|
6676
|
-
const [entry, ov, known] = yield fx.state.read((s) => {
|
|
6698
|
+
const [entry, ov, known, complete] = yield fx.state.read((s) => {
|
|
6677
6699
|
const e = s.queries.get(hash);
|
|
6678
6700
|
return [
|
|
6679
6701
|
e,
|
|
6680
6702
|
overlay(s),
|
|
6681
|
-
e?.lifecycle.phase === "cold" ? vouched(s) : NONE
|
|
6703
|
+
e?.lifecycle.phase === "cold" ? vouched(s) : NONE,
|
|
6704
|
+
needed(s, hash).length === 0
|
|
6682
6705
|
];
|
|
6683
6706
|
});
|
|
6684
6707
|
if (!entry) return;
|
|
@@ -6718,12 +6741,18 @@ function* materialize(hash) {
|
|
|
6718
6741
|
if (!current) return;
|
|
6719
6742
|
const t1 = yield fx.now();
|
|
6720
6743
|
const changed = !rowsEqual(rows, current.records);
|
|
6721
|
-
|
|
6744
|
+
const answered = complete && entry.lifecycle.phase !== "cold" && current.lifecycle.phase !== "cold" && current.remoteArray === entry.remoteArray;
|
|
6745
|
+
yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0, answered), changed ? stampUpdated(hash, t1) : (s) => s));
|
|
6722
6746
|
if (changed) yield fx.emit({
|
|
6723
6747
|
type: "query:records",
|
|
6724
6748
|
hash,
|
|
6725
6749
|
records: rows
|
|
6726
6750
|
});
|
|
6751
|
+
if (answered && !isAnswered(current.lifecycle)) yield fx.emit({
|
|
6752
|
+
type: "query:authority",
|
|
6753
|
+
hash,
|
|
6754
|
+
known: true
|
|
6755
|
+
});
|
|
6727
6756
|
}
|
|
6728
6757
|
/**
|
|
6729
6758
|
* A view update from the in-browser SSP: the query's local id-set moved.
|
|
@@ -7206,10 +7235,10 @@ function* rebindQueries() {
|
|
|
7206
7235
|
remoteArray: view?.ids ?? [],
|
|
7207
7236
|
localArray
|
|
7208
7237
|
}));
|
|
7209
|
-
if (
|
|
7238
|
+
if (isAnswered(entry.lifecycle)) yield fx.emit({
|
|
7210
7239
|
type: "query:authority",
|
|
7211
7240
|
hash: entry.def.hash,
|
|
7212
|
-
known:
|
|
7241
|
+
known: false
|
|
7213
7242
|
});
|
|
7214
7243
|
}
|
|
7215
7244
|
}
|
|
@@ -7688,7 +7717,7 @@ var Runtime = class {
|
|
|
7688
7717
|
this.authoritySubs.set(hash, set);
|
|
7689
7718
|
if (options.immediate) {
|
|
7690
7719
|
const entry = this.stateValue.queries.get(hash);
|
|
7691
|
-
if (entry) this.safely(() => cb(
|
|
7720
|
+
if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
|
|
7692
7721
|
}
|
|
7693
7722
|
return () => {
|
|
7694
7723
|
set.delete(cb);
|
|
@@ -15745,7 +15774,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15745
15774
|
return new TabsCoordinator({
|
|
15746
15775
|
tabId: s.tabId,
|
|
15747
15776
|
fingerprint: computeTabsFingerprint({
|
|
15748
|
-
coreVersion: "0.0.1-canary.
|
|
15777
|
+
coreVersion: "0.0.1-canary.334",
|
|
15749
15778
|
schemaHash: hash53(config.schemaSurql),
|
|
15750
15779
|
endpoint: config.database.endpoint ?? "",
|
|
15751
15780
|
namespace: config.database.namespace,
|
package/dist/types.d.ts
CHANGED
|
@@ -2069,6 +2069,12 @@ interface QueryState {
|
|
|
2069
2069
|
* always emits at least once even when its records are unchanged — otherwise
|
|
2070
2070
|
* an empty re-registered window would never notify and stay "loading". */
|
|
2071
2071
|
syncNotified?: boolean;
|
|
2072
|
+
/**
|
|
2073
|
+
* Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
|
|
2074
|
+
* `answered`): what the authority channel and the bindings'
|
|
2075
|
+
* `isAuthoritative()` report. Ephemeral.
|
|
2076
|
+
*/
|
|
2077
|
+
answered?: boolean;
|
|
2072
2078
|
/** Timer for TTL expiration. */
|
|
2073
2079
|
ttlTimer: NodeJS.Timeout | null;
|
|
2074
2080
|
/** TTL duration in milliseconds. */
|
|
@@ -2147,9 +2153,12 @@ interface QueryTimings {
|
|
|
2147
2153
|
type QueryUpdateCallback = (records: Record<string, any>[]) => void;
|
|
2148
2154
|
type QueryStatusCallback = (status: QueryStatus) => void;
|
|
2149
2155
|
/**
|
|
2150
|
-
* Observer for a query's authority flips: `true` once
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2156
|
+
* Observer for a query's authority flips: `true` once subscribers hold the
|
|
2157
|
+
* rendered rows of a known membership (a server answer whose bodies are
|
|
2158
|
+
* local, or the durable membership of an earlier session), delivered after
|
|
2159
|
+
* the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
|
|
2160
|
+
* not enough: a `cached` re-seed or a just-applied server set has no rows
|
|
2161
|
+
* painted yet. What the bindings' `isAuthoritative()` mirrors.
|
|
2153
2162
|
*/
|
|
2154
2163
|
type QueryAuthorityCallback = (known: boolean) => void;
|
|
2155
2164
|
type MutationCallback = (mutations: MutationEvent[]) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.334",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.334",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.334",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -158,9 +158,9 @@ describe('dropImpersonatedBucket', () => {
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
describe('rebindQueries', () => {
|
|
161
|
-
it('re-seeds from the new store, rebuilds the local view (tolerating failures),
|
|
161
|
+
it('re-seeds from the new store, rebuilds the local view (tolerating failures), withdraws authority it held', async () => {
|
|
162
162
|
const s = R.compose(
|
|
163
|
-
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' } })),
|
|
163
|
+
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live', answered: true }, records: [{ id: 't:9' }] })),
|
|
164
164
|
R.putQuery(buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } }))
|
|
165
165
|
)({ ...buildState(), sessionId: 's' });
|
|
166
166
|
const out = await runPure(rebindQueries(), {
|
|
@@ -177,11 +177,13 @@ describe('rebindQueries', () => {
|
|
|
177
177
|
},
|
|
178
178
|
});
|
|
179
179
|
expect(out.state.queries.get('a')!.lifecycle.phase).toBe('cold');
|
|
180
|
-
expect(out.state.queries.get('b')!.lifecycle
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
]);
|
|
180
|
+
expect(out.state.queries.get('b')!.lifecycle).toMatchObject({ phase: 'cached', answered: false });
|
|
181
|
+
// `b` found a durable view, but its rows are not painted until the
|
|
182
|
+
// render: announcing "known" here is what let an app read the empty
|
|
183
|
+
// `records` of a re-seed as "the user has none" and create a duplicate.
|
|
184
|
+
expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([{ type: 'query:authority', hash: 'a', known: false }]);
|
|
185
|
+
expect(out.state.queries.get('a')!.records).toEqual([{ id: 't:9' }]);
|
|
186
|
+
expect([...out.state.dirty].sort()).toEqual(['a', 'b']);
|
|
185
187
|
expect(out.emitted.some((e) => e.type === 'log')).toBe(true);
|
|
186
188
|
});
|
|
187
189
|
});
|
|
@@ -3,7 +3,7 @@ import type { Saga } from '../kernel/saga';
|
|
|
3
3
|
import type { RegisterResult } from '../kernel/effects';
|
|
4
4
|
import { fx } from '../kernel/effects';
|
|
5
5
|
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
6
|
-
import { seedLifecycle,
|
|
6
|
+
import { seedLifecycle, isAnswered } from '../state/lifecycle';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
|
|
9
9
|
import type { SagaEnv } from '../query/env';
|
|
@@ -126,8 +126,11 @@ export function* rebindQueries(): Saga<void> {
|
|
|
126
126
|
}
|
|
127
127
|
const lifecycle = seedLifecycle(isResolvedBefore(view));
|
|
128
128
|
yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// A re-seed is never answered, not even from a durable view: subscribers
|
|
130
|
+
// still hold the previous store's rows until `materialize` paints the new
|
|
131
|
+
// store's, and only that render flips authority back on.
|
|
132
|
+
if (isAnswered(entry.lifecycle)) {
|
|
133
|
+
yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
}
|
|
@@ -6,6 +6,7 @@ import { fx } from '../kernel/effects';
|
|
|
6
6
|
import type { Saga } from '../kernel/saga';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { Runtime } from './runtime';
|
|
9
|
+
import { rebindQueries } from '../boot/bucket-switch.saga';
|
|
9
10
|
|
|
10
11
|
const env = defaultEnv({ tables: [] } as any, { materializeDebounceMs: 5 });
|
|
11
12
|
const noop = () => {};
|
|
@@ -88,6 +89,45 @@ describe('Runtime state hooks', () => {
|
|
|
88
89
|
expect(seen).toEqual([[{ id: 'thing:1' }]]);
|
|
89
90
|
expect(rt.state.dirty.has('q')).toBe(false);
|
|
90
91
|
});
|
|
92
|
+
it('the bucket-switch window: a re-seeded query never reads "known and idle" before its rows are painted', async () => {
|
|
93
|
+
// A sign-in: the query was registered in the anon bucket (cold, nothing
|
|
94
|
+
// painted), then the switch re-seeds it from the user's durable view.
|
|
95
|
+
// Before the fix the re-seed announced authority with `records` still
|
|
96
|
+
// empty, so a binding's `isSettled() && !row` held for a beat and the app
|
|
97
|
+
// created a row the user already had.
|
|
98
|
+
const { a, rt } = make(
|
|
99
|
+
{
|
|
100
|
+
local: {
|
|
101
|
+
getById: async () => ({ ids: [['thing:1', 1]], confirmed: true }),
|
|
102
|
+
select: async (plan: any) => (plan.ids ?? []).map((id: any) => ({ id: `${id.table}:${id.id}` })),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{ ...buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })]), sessionId: 's' }
|
|
106
|
+
);
|
|
107
|
+
// What a binding derives `isSettled()` and `data()` from.
|
|
108
|
+
let known = false;
|
|
109
|
+
let idle = false;
|
|
110
|
+
let rows: unknown[] = [];
|
|
111
|
+
const verdicts: string[] = [];
|
|
112
|
+
const look = () => {
|
|
113
|
+
if (known && idle) verdicts.push(rows.length > 0 ? 'settled with rows' : 'settled empty');
|
|
114
|
+
};
|
|
115
|
+
rt.subscribe('q', (r) => ((rows = r), look()), { immediate: true });
|
|
116
|
+
rt.subscribeAuthority('q', (k) => ((known = k), look()), { immediate: true });
|
|
117
|
+
rt.subscribeStatus('q', (st) => ((idle = st === 'idle'), look()), { immediate: true });
|
|
118
|
+
|
|
119
|
+
await rt.run(rebindQueries());
|
|
120
|
+
expect(rt.state.queries.get('q')!.lifecycle).toMatchObject({ phase: 'cached', fetchDepth: 0, answered: false });
|
|
121
|
+
expect(idle).toBe(true);
|
|
122
|
+
expect(known).toBe(false);
|
|
123
|
+
|
|
124
|
+
a.timers.fire('mat:q');
|
|
125
|
+
await tick();
|
|
126
|
+
await tick();
|
|
127
|
+
expect(rows).toEqual([{ id: 'thing:1' }]);
|
|
128
|
+
expect(known).toBe(true);
|
|
129
|
+
expect(verdicts).toEqual(['settled with rows']);
|
|
130
|
+
});
|
|
91
131
|
it('status and authority subscriptions fire on transitions, immediate delivers the current value', async () => {
|
|
92
132
|
const { rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
|
|
93
133
|
const statuses: string[] = [];
|
package/src/client/runtime.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Lane, Saga } from '../kernel/saga';
|
|
|
7
7
|
import { runSaga } from '../kernel/saga';
|
|
8
8
|
import type { ClientState } from '../state/client-state';
|
|
9
9
|
import { emptyState } from '../state/client-state';
|
|
10
|
-
import {
|
|
10
|
+
import { isAnswered } from '../state/lifecycle';
|
|
11
11
|
import * as R from '../state/reducers';
|
|
12
12
|
import { fetchingQueryCount, pendingMutationCount, queryStatus } from '../state/selectors';
|
|
13
13
|
import type { SagaEnv } from '../query/env';
|
|
@@ -274,7 +274,7 @@ export class Runtime {
|
|
|
274
274
|
this.authoritySubs.set(hash, set);
|
|
275
275
|
if (options.immediate) {
|
|
276
276
|
const entry = this.stateValue.queries.get(hash);
|
|
277
|
-
if (entry) this.safely(() => cb(
|
|
277
|
+
if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
|
|
278
278
|
}
|
|
279
279
|
return () => {
|
|
280
280
|
set.delete(cb);
|
|
@@ -318,7 +318,9 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
318
318
|
// the panel: is the server's set known, has a non-empty one been seen
|
|
319
319
|
// this session, how many empty reads were ignored.
|
|
320
320
|
membershipKnown: q.config.membershipKnown === true,
|
|
321
|
-
|
|
321
|
+
// What the bindings' `isAuthoritative()` reads: the known set's rows
|
|
322
|
+
// are painted, not just the set read.
|
|
323
|
+
authoritative: q.answered === true,
|
|
322
324
|
viewLost: q.viewLost === true,
|
|
323
325
|
serverState: q.serverState ?? null,
|
|
324
326
|
// Detailed per-phase processing-time breakdown (SSP sub-phases, local/
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
});
|
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);
|
|
@@ -249,12 +257,14 @@ describe('bucket switch reducers', () => {
|
|
|
249
257
|
R.outboxReplace([buildOutboxItem()])
|
|
250
258
|
);
|
|
251
259
|
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() });
|
|
260
|
+
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
261
|
const en = s1.queries.get('a')!;
|
|
254
262
|
expect(en.def.id).toBe(id);
|
|
255
263
|
expect(en.lifecycle.phase).toBe('cached');
|
|
256
264
|
expect(en.remoteArray).toEqual([['t:2', 1]]);
|
|
257
|
-
|
|
265
|
+
// What subscribers still hold: the next render diffs against it, so a new
|
|
266
|
+
// store that renders `[]` is a change they hear about.
|
|
267
|
+
expect(en.records).toEqual([{ id: 't:1' }]);
|
|
258
268
|
expect(en.serverState).toBeNull();
|
|
259
269
|
expect(en.registerAttempts).toBe(0);
|
|
260
270
|
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]) };
|
|
@@ -454,7 +459,13 @@ export const setIdentity =
|
|
|
454
459
|
): Reducer =>
|
|
455
460
|
(s) => ({ ...s, ...patch });
|
|
456
461
|
|
|
457
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* Re-home a query after a bucket switch: new store, same hash, fresh sync state.
|
|
464
|
+
* `records` is kept: it is what subscribers were last handed, and the next
|
|
465
|
+
* render diffs against it. Blanking it here (without telling anyone) let a
|
|
466
|
+
* new store that renders `[]` read as unchanged, so the previous principal's
|
|
467
|
+
* rows stayed on screen under an authority flip that vouched for them.
|
|
468
|
+
*/
|
|
458
469
|
export const rebindQuery =
|
|
459
470
|
(
|
|
460
471
|
hash: QueryHash,
|
|
@@ -468,7 +479,6 @@ export const rebindQuery =
|
|
|
468
479
|
remoteArray: next.remoteArray,
|
|
469
480
|
localArray: next.localArray,
|
|
470
481
|
subqueryRemoteArray: [],
|
|
471
|
-
records: [],
|
|
472
482
|
serverState: null,
|
|
473
483
|
lastHeartbeatAt: null,
|
|
474
484
|
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;
|