@spooky-sync/core 0.0.1-canary.332 → 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 +14 -2
- package/dist/index.js +124 -26
- package/dist/types.d.ts +46 -4
- 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/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/index.ts +68 -1
- package/src/modules/devtools/index.ts +3 -1
- package/src/query/anon-query-isolation.integration.test.ts +7 -3
- 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/query/session-guard.integration.test.ts +302 -0
- 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/src/utils/error-classification.test.ts +19 -1
- package/src/utils/error-classification.ts +12 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { AuthService, REFUSAL_RECHECK_MIN_MS } from './index';
|
|
3
|
+
import {
|
|
4
|
+
createDatabaseEventSystem,
|
|
5
|
+
DatabaseEventTypes,
|
|
6
|
+
} from '../../services/database/events/index';
|
|
7
|
+
|
|
8
|
+
// The session is re-checked after every reconnect and after a statement the
|
|
9
|
+
// server refused because of the session: a token refused on a socket's own
|
|
10
|
+
// handshake used to leave the page on an anonymous `connected` socket while
|
|
11
|
+
// it kept announcing the user.
|
|
12
|
+
|
|
13
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
14
|
+
return `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const silentLogger = () =>
|
|
18
|
+
({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: () => silentLogger() }) as any;
|
|
19
|
+
|
|
20
|
+
const REFUSED = 'There was a problem with authentication: this session has ended, sign in again';
|
|
21
|
+
|
|
22
|
+
function makeAuth() {
|
|
23
|
+
const token = jwt({ AC: 'account', ID: 'user:abc' });
|
|
24
|
+
const store = new Map<string, unknown>([['sp00ky_auth_token', token]]);
|
|
25
|
+
const persistence = {
|
|
26
|
+
get: vi.fn(async (k: string) => store.get(k) ?? null),
|
|
27
|
+
set: vi.fn(async (k: string, v: unknown) => void store.set(k, v)),
|
|
28
|
+
remove: vi.fn(async (k: string) => void store.delete(k)),
|
|
29
|
+
} as any;
|
|
30
|
+
const connected = new Set<() => void>();
|
|
31
|
+
const events = createDatabaseEventSystem();
|
|
32
|
+
let refuse = false;
|
|
33
|
+
const authenticate = vi.fn(async () => {
|
|
34
|
+
if (refuse) throw new Error(REFUSED);
|
|
35
|
+
});
|
|
36
|
+
const remote = {
|
|
37
|
+
query: vi.fn(async () => [{ id: 'user:abc' }]),
|
|
38
|
+
setAuthToken: vi.fn(),
|
|
39
|
+
whenConnected: vi.fn(async () => undefined),
|
|
40
|
+
subscribeConnection: (_event: 'connected', cb: () => void) => {
|
|
41
|
+
connected.add(cb);
|
|
42
|
+
return () => connected.delete(cb);
|
|
43
|
+
},
|
|
44
|
+
getEvents: () => events,
|
|
45
|
+
getClient: () => ({ authenticate, invalidate: vi.fn(async () => undefined) }),
|
|
46
|
+
} as any;
|
|
47
|
+
const auth = new AuthService({} as any, remote, persistence, silentLogger());
|
|
48
|
+
const settle = () => new Promise((r) => setTimeout(r, 0));
|
|
49
|
+
return {
|
|
50
|
+
auth,
|
|
51
|
+
authenticate,
|
|
52
|
+
store,
|
|
53
|
+
refuseFromNowOn: () => void (refuse = true),
|
|
54
|
+
reconnect: async () => {
|
|
55
|
+
for (const cb of connected) cb();
|
|
56
|
+
await settle();
|
|
57
|
+
},
|
|
58
|
+
statementFails: async (error: string) => {
|
|
59
|
+
events.emit(DatabaseEventTypes.RemoteQuery, {
|
|
60
|
+
query: 'RETURN fn::query::register($config)',
|
|
61
|
+
duration: 1,
|
|
62
|
+
success: false,
|
|
63
|
+
error,
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
});
|
|
66
|
+
await settle();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('AuthService session re-check', () => {
|
|
72
|
+
it('signs out when a reconnected socket can no longer use the token', async () => {
|
|
73
|
+
const t = makeAuth();
|
|
74
|
+
await t.auth.init();
|
|
75
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
76
|
+
|
|
77
|
+
t.refuseFromNowOn();
|
|
78
|
+
await t.reconnect();
|
|
79
|
+
|
|
80
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
81
|
+
expect(t.auth.isAuthenticated).toBe(false);
|
|
82
|
+
expect(t.store.get('sp00ky_auth_token')).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps a session the server still accepts after a reconnect', async () => {
|
|
86
|
+
const t = makeAuth();
|
|
87
|
+
await t.auth.init();
|
|
88
|
+
await t.reconnect();
|
|
89
|
+
|
|
90
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
91
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('signs out when a statement on an open socket is refused for the session', async () => {
|
|
95
|
+
const t = makeAuth();
|
|
96
|
+
await t.auth.init();
|
|
97
|
+
|
|
98
|
+
t.refuseFromNowOn();
|
|
99
|
+
await t.statementFails(`An error occurred: ${REFUSED}`);
|
|
100
|
+
|
|
101
|
+
expect(t.auth.isAuthenticated).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('ignores statement errors that are not about the session', async () => {
|
|
105
|
+
const t = makeAuth();
|
|
106
|
+
await t.auth.init();
|
|
107
|
+
await t.statementFails('Found 3 for field `n`, but expected a string');
|
|
108
|
+
await t.statementFails('There was a problem with the underlying connection');
|
|
109
|
+
|
|
110
|
+
expect(t.authenticate).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('re-checks once per window however many statements are refused', async () => {
|
|
114
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
115
|
+
try {
|
|
116
|
+
const t = makeAuth();
|
|
117
|
+
await t.auth.init();
|
|
118
|
+
await t.statementFails(REFUSED);
|
|
119
|
+
await t.statementFails(REFUSED);
|
|
120
|
+
expect(t.authenticate).toHaveBeenCalledTimes(2);
|
|
121
|
+
|
|
122
|
+
vi.setSystemTime(Date.now() + REFUSAL_RECHECK_MIN_MS);
|
|
123
|
+
await t.statementFails(REFUSED);
|
|
124
|
+
expect(t.authenticate).toHaveBeenCalledTimes(3);
|
|
125
|
+
expect(t.auth.isAuthenticated).toBe(true);
|
|
126
|
+
} finally {
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does nothing for a signed-out page', async () => {
|
|
132
|
+
const t = makeAuth();
|
|
133
|
+
t.store.clear();
|
|
134
|
+
await t.auth.init();
|
|
135
|
+
await t.reconnect();
|
|
136
|
+
await t.statementFails(REFUSED);
|
|
137
|
+
|
|
138
|
+
expect(t.authenticate).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -9,7 +9,8 @@ import type { Logger } from '../../services/logger/index';
|
|
|
9
9
|
export * from './events/index';
|
|
10
10
|
import { AuthEventTypes, createAuthEventSystem } from './events/index';
|
|
11
11
|
import type { PersistenceClient } from '../../types';
|
|
12
|
-
import { classifySyncError } from '../../utils/error-classification';
|
|
12
|
+
import { classifySyncError, isRefusedSessionError } from '../../utils/error-classification';
|
|
13
|
+
import { DatabaseEventTypes } from '../../services/database/events/index';
|
|
13
14
|
import { encodeRecordId } from '../../utils/index';
|
|
14
15
|
import { RecordId } from 'surrealdb';
|
|
15
16
|
import {
|
|
@@ -53,6 +54,13 @@ export type SignOutHook = (ctx: SignOutContext) => Promise<void> | void;
|
|
|
53
54
|
/** Upper bound on the before-sign-out hooks, so a dead socket never holds a sign-out. */
|
|
54
55
|
export const SIGN_OUT_HOOK_TIMEOUT_MS = 2_000;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Floor between two session re-checks started by refused statements. A
|
|
59
|
+
* refused session fails every statement at once, and a check that keeps the
|
|
60
|
+
* session (the server was unreachable) must not be re-run by each of them.
|
|
61
|
+
*/
|
|
62
|
+
export const REFUSAL_RECHECK_MIN_MS = 30_000;
|
|
63
|
+
|
|
56
64
|
// Helper to pretty print types
|
|
57
65
|
type Prettify<T> = {
|
|
58
66
|
[K in keyof T]: T[K];
|
|
@@ -111,6 +119,11 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
111
119
|
private stopping: Promise<void> | null = null;
|
|
112
120
|
/** A token renewal in flight, which a stop waits out (see `renewImpersonation`). */
|
|
113
121
|
private renewing: Promise<void> | null = null;
|
|
122
|
+
/** The session re-check in flight (see `watchSession`), shared by every trigger. */
|
|
123
|
+
private reverifying: Promise<void> | null = null;
|
|
124
|
+
/** When a refused statement last started a re-check. */
|
|
125
|
+
private lastRefusalCheckAt = Number.NEGATIVE_INFINITY;
|
|
126
|
+
private watchingSession = false;
|
|
114
127
|
private impersonationTimers = new ImpersonationTimers({
|
|
115
128
|
current: () => this.impersonation,
|
|
116
129
|
renew: () => this.renewImpersonation(),
|
|
@@ -141,6 +154,60 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
141
154
|
|
|
142
155
|
async init() {
|
|
143
156
|
await this.check();
|
|
157
|
+
this.watchSession();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check the session again whenever the socket comes back, and whenever the
|
|
162
|
+
* server refuses a statement because of the session.
|
|
163
|
+
*
|
|
164
|
+
* The server judges a token only when a socket authenticates: the SDK
|
|
165
|
+
* replays `authenticate` on its own reconnects, `RemoteDatabaseService` on a
|
|
166
|
+
* socket it rebuilds. When the replay is refused (the app ended the session,
|
|
167
|
+
* say with a password reset) the SDK drops to an anonymous session, and the
|
|
168
|
+
* supervisor swallows the rebuild's error, so the socket reports `connected`
|
|
169
|
+
* while this service still announces the user. Every view registered from
|
|
170
|
+
* then on carried no identity: the page looked signed in and showed nothing
|
|
171
|
+
* of the user's. `check()` applies the token again and signs out when the
|
|
172
|
+
* server refuses it.
|
|
173
|
+
*
|
|
174
|
+
* A socket that never reconnects is ended by the app's session guard
|
|
175
|
+
* (`fn::sp00ky::session_live`) instead: its heartbeats stop finding rows,
|
|
176
|
+
* and the re-registration that follows is refused with "There was a problem
|
|
177
|
+
* with authentication", which starts a check here too.
|
|
178
|
+
*/
|
|
179
|
+
private watchSession(): void {
|
|
180
|
+
if (this.watchingSession) return;
|
|
181
|
+
this.watchingSession = true;
|
|
182
|
+
this.remote.subscribeConnection('connected', () => this.reverify());
|
|
183
|
+
this.remote.getEvents().subscribe(DatabaseEventTypes.RemoteQuery, (event) => {
|
|
184
|
+
const { success, error } = event.payload;
|
|
185
|
+
if (success || !isRefusedSessionError(error ?? '')) return;
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (now - this.lastRefusalCheckAt < REFUSAL_RECHECK_MIN_MS) return;
|
|
188
|
+
this.lastRefusalCheckAt = now;
|
|
189
|
+
this.reverify();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One `check()` for every trigger that lands while it runs. */
|
|
194
|
+
private reverify(): void {
|
|
195
|
+
if (!this.isAuthenticated || this.reverifying) return;
|
|
196
|
+
this.reverifying = (async () => {
|
|
197
|
+
// A rebuilt socket reports `connected` before its namespace and token
|
|
198
|
+
// are applied: check against the finished handshake.
|
|
199
|
+
await this.remote.whenConnected();
|
|
200
|
+
if (this.isAuthenticated) await this.check();
|
|
201
|
+
})()
|
|
202
|
+
.catch((error) => {
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
{ error, Category: 'sp00ky-client::AuthService::reverify' },
|
|
205
|
+
'Session re-check failed'
|
|
206
|
+
);
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
209
|
+
this.reverifying = null;
|
|
210
|
+
});
|
|
144
211
|
}
|
|
145
212
|
|
|
146
213
|
getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
|
|
@@ -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/
|
|
@@ -58,14 +58,18 @@ function ownerGuard(): string {
|
|
|
58
58
|
|
|
59
59
|
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Just the session guard (its rule's user branch asks it) and the `_00_query`
|
|
63
|
+
* block: the rest of the file needs tables and modules this test does not.
|
|
64
|
+
*/
|
|
62
65
|
function queryTableSchema(): string {
|
|
63
66
|
const meta = cliSource('meta_tables_remote.surql');
|
|
67
|
+
const guard = meta.indexOf('-- SPOOKY SESSION GUARD');
|
|
64
68
|
const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
|
|
65
69
|
const end = meta.indexOf('-- SPOOKY LIST REF');
|
|
66
|
-
if (
|
|
70
|
+
if (guard < 0 || start < guard || end < start)
|
|
67
71
|
throw new Error('_00_query block not found in meta_tables_remote.surql');
|
|
68
|
-
return meta.slice(start, end);
|
|
72
|
+
return meta.slice(guard, meta.indexOf('-- SPOOKY INCANTATION')) + meta.slice(start, end);
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const ACCESS = `
|
|
@@ -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) {
|