@spooky-sync/core 0.0.1-canary.35 → 0.0.1-canary.351
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 +90 -0
- package/README.md +59 -16
- package/dist/index.d.ts +2135 -142
- package/dist/index.js +16570 -2575
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +401 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1917 -38
- package/dist/worker-edges.js +498 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +37 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +174 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +191 -0
- package/src/boot/bucket-switch.saga.ts +151 -0
- package/src/boot/preload.saga.test.ts +96 -0
- package/src/boot/preload.saga.ts +45 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +102 -0
- package/src/client/runtime.test.ts +299 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +437 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +171 -0
- package/src/kernel/events.ts +59 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +543 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +546 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +821 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +748 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +361 -0
- package/src/mutation/push.saga.ts +245 -0
- package/src/mutation/rows.test.ts +278 -0
- package/src/mutation/rows.ts +499 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +531 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +188 -0
- package/src/query/materialize.saga.ts +96 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +482 -0
- package/src/query/membership.saga.ts +315 -0
- package/src/query/membership.test.ts +151 -0
- package/src/query/membership.ts +234 -0
- package/src/query/register.saga.test.ts +369 -0
- package/src/query/register.saga.ts +293 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/session-guard.integration.test.ts +304 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +174 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +216 -0
- package/src/state/lifecycle.test.ts +184 -0
- package/src/state/lifecycle.ts +127 -0
- package/src/state/reducers.test.ts +319 -0
- package/src/state/reducers.ts +550 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +259 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +229 -0
- package/src/sync/live.saga.ts +116 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +661 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +57 -0
- package/src/utils/error-classification.test.ts +105 -0
- package/src/utils/error-classification.ts +64 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
|
|
3
|
+
import { deriveStatus, hasServerMembership, isAnswered, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
|
|
4
|
+
|
|
5
|
+
const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
|
|
6
|
+
phase,
|
|
7
|
+
remote: 'unregistered',
|
|
8
|
+
fetchDepth: 0,
|
|
9
|
+
backgroundDepth: 0,
|
|
10
|
+
notified: false,
|
|
11
|
+
answered: false,
|
|
12
|
+
...over,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('transition table (phase axis)', () => {
|
|
16
|
+
it('seed', () => {
|
|
17
|
+
expect(transition(at('live'), { type: 'seed', resolvedBefore: true }).phase).toBe('cached');
|
|
18
|
+
expect(transition(at('live'), { type: 'seed', resolvedBefore: false }).phase).toBe('cold');
|
|
19
|
+
});
|
|
20
|
+
it('membership-applied moves every phase to live', () => {
|
|
21
|
+
for (const p of ['cold', 'cached', 'live'] as const) {
|
|
22
|
+
expect(transition(at(p), { type: 'membership-applied', present: true }).phase).toBe('live');
|
|
23
|
+
expect(transition(at(p), { type: 'membership-applied', present: false }).phase).toBe('live');
|
|
24
|
+
}
|
|
25
|
+
expect(transition(at('view-lost'), { type: 'membership-applied', present: true }).phase).toBe('live');
|
|
26
|
+
});
|
|
27
|
+
it('view-lost needs a present row to recover', () => {
|
|
28
|
+
expect(() => transition(at('view-lost'), { type: 'membership-applied', present: false })).toThrow(LifecycleError);
|
|
29
|
+
});
|
|
30
|
+
it('row-missing: cold stays cold, everything else goes view-lost', () => {
|
|
31
|
+
expect(transition(at('cold'), { type: 'row-missing' }).phase).toBe('cold');
|
|
32
|
+
expect(transition(at('cached'), { type: 'row-missing' }).phase).toBe('view-lost');
|
|
33
|
+
expect(transition(at('live'), { type: 'row-missing' }).phase).toBe('view-lost');
|
|
34
|
+
expect(transition(at('view-lost'), { type: 'row-missing' }).phase).toBe('view-lost');
|
|
35
|
+
});
|
|
36
|
+
it('bucket-switch reseeds and clears fetch depth', () => {
|
|
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);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('remote / fetch / notified axes', () => {
|
|
83
|
+
it('remote transitions', () => {
|
|
84
|
+
let l = at('cached');
|
|
85
|
+
l = transition(l, { type: 'remote-registering' });
|
|
86
|
+
expect(l.remote).toBe('registering');
|
|
87
|
+
l = transition(l, { type: 'remote-registered' });
|
|
88
|
+
expect(l.remote).toBe('registered');
|
|
89
|
+
l = transition(l, { type: 'notified' });
|
|
90
|
+
expect(l.notified).toBe(true);
|
|
91
|
+
l = transition(l, { type: 'remote-dropped' });
|
|
92
|
+
expect(l).toMatchObject({ remote: 'unregistered', notified: false });
|
|
93
|
+
l = transition(l, { type: 'remote-failed' });
|
|
94
|
+
expect(l.remote).toBe('failed');
|
|
95
|
+
});
|
|
96
|
+
it('fetch depth is a refcount that never goes negative', () => {
|
|
97
|
+
let l = at('live');
|
|
98
|
+
l = transition(l, { type: 'fetch-begin' });
|
|
99
|
+
l = transition(l, { type: 'fetch-begin' });
|
|
100
|
+
expect(deriveStatus(l)).toBe('fetching');
|
|
101
|
+
l = transition(l, { type: 'fetch-end' });
|
|
102
|
+
expect(deriveStatus(l)).toBe('fetching');
|
|
103
|
+
l = transition(l, { type: 'fetch-end' });
|
|
104
|
+
expect(deriveStatus(l)).toBe('idle');
|
|
105
|
+
const same = transition(l, { type: 'fetch-end' });
|
|
106
|
+
expect(same).toBe(l);
|
|
107
|
+
});
|
|
108
|
+
it('background cycles hold fetching but are not foreground', () => {
|
|
109
|
+
let l = at('cached');
|
|
110
|
+
l = transition(l, { type: 'fetch-begin', background: true });
|
|
111
|
+
expect(deriveStatus(l)).toBe('fetching');
|
|
112
|
+
expect(isForegroundFetching(l)).toBe(false);
|
|
113
|
+
l = transition(l, { type: 'fetch-begin' });
|
|
114
|
+
expect(isForegroundFetching(l)).toBe(true);
|
|
115
|
+
l = transition(l, { type: 'fetch-end' });
|
|
116
|
+
expect(l).toMatchObject({ fetchDepth: 1, backgroundDepth: 1 });
|
|
117
|
+
expect(isForegroundFetching(l)).toBe(false);
|
|
118
|
+
l = transition(l, { type: 'fetch-end', background: true });
|
|
119
|
+
expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
120
|
+
expect(deriveStatus(l)).toBe('idle');
|
|
121
|
+
});
|
|
122
|
+
it('an unbalanced end never leaves more background cycles than cycles', () => {
|
|
123
|
+
const l = transition(at('live', { fetchDepth: 1, backgroundDepth: 1 }), { type: 'fetch-end' });
|
|
124
|
+
expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
|
|
125
|
+
});
|
|
126
|
+
it('notified is idempotent', () => {
|
|
127
|
+
const l = transition(at('live'), { type: 'notified' });
|
|
128
|
+
expect(transition(l, { type: 'notified' })).toBe(l);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('derived predicates', () => {
|
|
133
|
+
it('authority and server membership follow the phase', () => {
|
|
134
|
+
expect(isAuthoritative(at('cold'))).toBe(false);
|
|
135
|
+
expect(isAuthoritative(at('cached'))).toBe(true);
|
|
136
|
+
expect(hasServerMembership(at('cached'))).toBe(false);
|
|
137
|
+
expect(hasServerMembership(at('live'))).toBe(true);
|
|
138
|
+
expect(hasServerMembership(at('view-lost'))).toBe(true);
|
|
139
|
+
expect(seedLifecycle(true).phase).toBe('cached');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('invariants over random event sequences', () => {
|
|
144
|
+
const events: LifecycleEvent[] = [
|
|
145
|
+
{ type: 'membership-applied', present: true },
|
|
146
|
+
{ type: 'row-missing' },
|
|
147
|
+
{ type: 'remote-registering' },
|
|
148
|
+
{ type: 'remote-registered' },
|
|
149
|
+
{ type: 'remote-dropped' },
|
|
150
|
+
{ type: 'fetch-begin' },
|
|
151
|
+
{ type: 'fetch-end' },
|
|
152
|
+
{ type: 'fetch-begin', background: true },
|
|
153
|
+
{ type: 'fetch-end', background: true },
|
|
154
|
+
{ type: 'notified' },
|
|
155
|
+
{ type: 'notified', answered: true },
|
|
156
|
+
{ type: 'bucket-switch', resolvedBefore: false },
|
|
157
|
+
{ type: 'seed', resolvedBefore: true },
|
|
158
|
+
];
|
|
159
|
+
// Deterministic LCG so the sequence is reproducible.
|
|
160
|
+
let seed = 42;
|
|
161
|
+
const rand = () => (seed = (seed * 1664525 + 1013904223) % 2 ** 32) / 2 ** 32;
|
|
162
|
+
|
|
163
|
+
it('0 <= backgroundDepth <= fetchDepth, notified only after live-or-cached, view-lost never from cold', () => {
|
|
164
|
+
for (let run = 0; run < 200; run++) {
|
|
165
|
+
let l = seedLifecycle(rand() < 0.5);
|
|
166
|
+
for (let i = 0; i < 25; i++) {
|
|
167
|
+
const ev = events[Math.floor(rand() * events.length)];
|
|
168
|
+
const before = l;
|
|
169
|
+
try {
|
|
170
|
+
l = transition(l, ev);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
expect(err).toBeInstanceOf(LifecycleError);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
expect(l.fetchDepth).toBeGreaterThanOrEqual(0);
|
|
176
|
+
expect(l.backgroundDepth).toBeGreaterThanOrEqual(0);
|
|
177
|
+
expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
|
|
178
|
+
if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
|
|
179
|
+
if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
|
|
180
|
+
if (l.answered) expect(l.phase).not.toBe('cold');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { QueryStatus } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The query lifecycle as one explicit machine.
|
|
5
|
+
*
|
|
6
|
+
* `phase` answers "where do this query's rows come from":
|
|
7
|
+
* - `cold` never resolved on this device: rows come from a local
|
|
8
|
+
* predicate scan (or the SSP local window), bindings show a loader.
|
|
9
|
+
* - `cached` a durable `_00_view` row was found: rows come from that id-set.
|
|
10
|
+
* - `live` a server membership set was accepted this session.
|
|
11
|
+
* - `view-lost` the server's `_00_query` row vanished while we held
|
|
12
|
+
* membership; rows are kept, a re-registration is under way.
|
|
13
|
+
*
|
|
14
|
+
* `remote` tracks the server-side registration, `fetchDepth` is the refcount
|
|
15
|
+
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
16
|
+
* at least one materialization this registration.
|
|
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
|
+
*
|
|
29
|
+
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
30
|
+
* query already shows: a registration of a query that is not `cold`. It still
|
|
31
|
+
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
32
|
+
* traffic the user waits on, so the activity indicator leaves it out.
|
|
33
|
+
*/
|
|
34
|
+
export type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
|
|
35
|
+
export type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
|
|
36
|
+
|
|
37
|
+
export interface QueryLifecycle {
|
|
38
|
+
readonly phase: QueryPhase;
|
|
39
|
+
readonly remote: RemotePhase;
|
|
40
|
+
readonly fetchDepth: number;
|
|
41
|
+
readonly backgroundDepth: number;
|
|
42
|
+
readonly notified: boolean;
|
|
43
|
+
readonly answered: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type LifecycleEvent =
|
|
47
|
+
| { type: 'seed'; resolvedBefore: boolean }
|
|
48
|
+
| { type: 'membership-applied'; present: boolean }
|
|
49
|
+
| { type: 'row-missing' }
|
|
50
|
+
| { type: 'remote-registering' }
|
|
51
|
+
| { type: 'remote-registered' }
|
|
52
|
+
| { type: 'remote-failed' }
|
|
53
|
+
| { type: 'remote-dropped' }
|
|
54
|
+
| { type: 'fetch-begin'; background?: boolean }
|
|
55
|
+
| { type: 'fetch-end'; background?: boolean }
|
|
56
|
+
| { type: 'notified'; answered?: boolean }
|
|
57
|
+
| { type: 'bucket-switch'; resolvedBefore: boolean };
|
|
58
|
+
|
|
59
|
+
export class LifecycleError extends Error {
|
|
60
|
+
constructor(lifecycle: QueryLifecycle, event: LifecycleEvent) {
|
|
61
|
+
super(`Impossible lifecycle transition: ${lifecycle.phase}/${lifecycle.remote} + ${event.type}`);
|
|
62
|
+
this.name = 'LifecycleError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
|
|
67
|
+
return {
|
|
68
|
+
phase: resolvedBefore ? 'cached' : 'cold',
|
|
69
|
+
remote: 'unregistered',
|
|
70
|
+
fetchDepth: 0,
|
|
71
|
+
backgroundDepth: 0,
|
|
72
|
+
notified: false,
|
|
73
|
+
answered: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
|
|
78
|
+
export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycle {
|
|
79
|
+
switch (ev.type) {
|
|
80
|
+
case 'seed':
|
|
81
|
+
return seedLifecycle(ev.resolvedBefore);
|
|
82
|
+
case 'membership-applied':
|
|
83
|
+
if (l.phase === 'view-lost' && !ev.present) throw new LifecycleError(l, ev);
|
|
84
|
+
return { ...l, phase: 'live' };
|
|
85
|
+
case 'row-missing':
|
|
86
|
+
// A cold query holds nothing the server could have lost: the outcome
|
|
87
|
+
// is `ignored` upstream and the phase does not move.
|
|
88
|
+
return l.phase === 'cold' ? l : { ...l, phase: 'view-lost' };
|
|
89
|
+
case 'remote-registering':
|
|
90
|
+
return { ...l, remote: 'registering' };
|
|
91
|
+
case 'remote-registered':
|
|
92
|
+
return { ...l, remote: 'registered' };
|
|
93
|
+
case 'remote-failed':
|
|
94
|
+
return { ...l, remote: 'failed' };
|
|
95
|
+
case 'remote-dropped':
|
|
96
|
+
return { ...l, remote: 'unregistered', notified: false };
|
|
97
|
+
case 'fetch-begin':
|
|
98
|
+
return { ...l, fetchDepth: l.fetchDepth + 1, backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0) };
|
|
99
|
+
case 'fetch-end': {
|
|
100
|
+
if (l.fetchDepth === 0) return l;
|
|
101
|
+
const fetchDepth = l.fetchDepth - 1;
|
|
102
|
+
// Never more background cycles than cycles: an unbalanced end clamps.
|
|
103
|
+
const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
|
|
104
|
+
return { ...l, fetchDepth, backgroundDepth };
|
|
105
|
+
}
|
|
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
|
+
}
|
|
111
|
+
case 'bucket-switch':
|
|
112
|
+
return { ...seedLifecycle(ev.resolvedBefore), fetchDepth: 0 };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Membership is known (server set or durable seed); its rows may not be painted yet. */
|
|
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;
|
|
123
|
+
export const hasServerMembership = (l: QueryLifecycle): boolean =>
|
|
124
|
+
l.phase === 'live' || l.phase === 'view-lost';
|
|
125
|
+
export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
|
|
126
|
+
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
127
|
+
export const isForegroundFetching = (l: QueryLifecycle): boolean => l.fetchDepth > l.backgroundDepth;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as R from './reducers';
|
|
3
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
4
|
+
import { emptyState } from './client-state';
|
|
5
|
+
|
|
6
|
+
const e = (hash: string, over: Parameters<typeof buildEntry>[0] = {}) => buildEntry({ ...over, def: { hash, ...over.def } });
|
|
7
|
+
|
|
8
|
+
describe('query reducers', () => {
|
|
9
|
+
it('putQuery adds and dirties; removeQuery clears dirt', () => {
|
|
10
|
+
let s = R.putQuery(e('a'))(emptyState({ tabId: 't' }));
|
|
11
|
+
expect(s.queries.has('a')).toBe(true);
|
|
12
|
+
expect(s.dirty.has('a')).toBe(true);
|
|
13
|
+
s = R.markMembershipDirty(['a'])(s);
|
|
14
|
+
s = R.removeQuery('a')(s);
|
|
15
|
+
expect(s.queries.size).toBe(0);
|
|
16
|
+
expect(s.dirty.size).toBe(0);
|
|
17
|
+
expect(s.membershipDirty.size).toBe(0);
|
|
18
|
+
expect(R.removeQuery('zzz')(s)).toBe(s);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reducers on an unknown hash are identity', () => {
|
|
22
|
+
const s = buildState([e('a')]);
|
|
23
|
+
expect(R.applyLifecycle('x', { type: 'notified' })(s)).toBe(s);
|
|
24
|
+
expect(R.setLocalArray('x', [])(s)).toBe(s);
|
|
25
|
+
expect(R.commitMembership('x', [['thing:1', 1]], true)(s)).toBe(s);
|
|
26
|
+
expect(R.subscribe('x')(s)).toBe(s);
|
|
27
|
+
expect(R.setRecords('x', [], true, 1)(s)).toBe(s);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('applyLifecycle / setServerState', () => {
|
|
31
|
+
const s0 = buildState([e('a')]);
|
|
32
|
+
const s1 = R.applyLifecycle('a', { type: 'remote-registering' })(s0);
|
|
33
|
+
expect(s1.queries.get('a')!.lifecycle.remote).toBe('registering');
|
|
34
|
+
const s2 = R.setServerState('a', 'ready')(s1);
|
|
35
|
+
expect(s2.queries.get('a')!.serverState).toBe('ready');
|
|
36
|
+
expect(R.setServerState('a', 'ready')(s2)).toBe(s2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('commitMembership sets the array, flips live, dirties, and releases acked items it names', () => {
|
|
40
|
+
const s0 = buildState(
|
|
41
|
+
[e('a', { lifecycle: { phase: 'cached' } })],
|
|
42
|
+
R.outboxReplace([
|
|
43
|
+
buildOutboxItem({ id: 'm1', recordId: 'thing:1', status: 'acked', ackedAt: 1 }),
|
|
44
|
+
buildOutboxItem({ id: 'm2', recordId: 'thing:2', status: 'acked', ackedAt: 1 }),
|
|
45
|
+
buildOutboxItem({ id: 'm3', recordId: 'thing:1', status: 'pending' }),
|
|
46
|
+
])
|
|
47
|
+
);
|
|
48
|
+
const s1 = R.commitMembership('a', [['thing:1', 2]], true)({ ...s0, dirty: new Set() });
|
|
49
|
+
const entry = s1.queries.get('a')!;
|
|
50
|
+
expect(entry.remoteArray).toEqual([['thing:1', 2]]);
|
|
51
|
+
expect(entry.lifecycle.phase).toBe('live');
|
|
52
|
+
expect(s1.dirty.has('a')).toBe(true);
|
|
53
|
+
expect(s1.outbox.map((i) => i.id)).toEqual(['m2', 'm3']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('setLocalArray dirties; setSubqueryRemoteArray does not', () => {
|
|
57
|
+
const s0 = buildState([e('a')]);
|
|
58
|
+
const s1 = R.setLocalArray('a', [['thing:1', 1]])(s0);
|
|
59
|
+
expect(s1.dirty.has('a')).toBe(true);
|
|
60
|
+
const s2 = R.setSubqueryRemoteArray('a', [['child:1', 1]])({ ...s1, dirty: new Set() });
|
|
61
|
+
expect(s2.dirty.size).toBe(0);
|
|
62
|
+
expect(s2.queries.get('a')!.subqueryRemoteArray).toEqual([['child:1', 1]]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('setRecords clears dirt, counts changes, samples timing, marks notified', () => {
|
|
66
|
+
const s0 = R.markDirty(['a'])(buildState([e('a')]));
|
|
67
|
+
const rows = [{ id: 'thing:1' }];
|
|
68
|
+
const s1 = R.setRecords('a', rows, true, 12)(s0);
|
|
69
|
+
const en = s1.queries.get('a')!;
|
|
70
|
+
expect(en.records).toBe(rows);
|
|
71
|
+
expect(en.telemetry.updateCount).toBe(1);
|
|
72
|
+
expect(en.telemetry.phaseSamples.localFetch).toEqual([12]);
|
|
73
|
+
expect(en.telemetry.phaseLast.localFetch).toBe(12);
|
|
74
|
+
expect(en.lifecycle.notified).toBe(true);
|
|
75
|
+
expect(s1.dirty.has('a')).toBe(false);
|
|
76
|
+
const s2 = R.setRecords('a', [{ id: 'other' }], false, null)(s1);
|
|
77
|
+
expect(s2.queries.get('a')!.records).toBe(rows);
|
|
78
|
+
expect(s2.queries.get('a')!.telemetry.updateCount).toBe(1);
|
|
79
|
+
expect(s2.queries.get('a')!.telemetry.phaseSamples.localFetch).toEqual([12]);
|
|
80
|
+
});
|
|
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
|
+
|
|
90
|
+
it('sample windows are capped', () => {
|
|
91
|
+
let s = buildState([e('a')]);
|
|
92
|
+
for (let i = 0; i < 105; i++) s = R.recordPhase('a', 'localFetch', i)(s);
|
|
93
|
+
const t = s.queries.get('a')!.telemetry;
|
|
94
|
+
expect(t.phaseSamples.localFetch).toHaveLength(100);
|
|
95
|
+
expect(t.phaseLast.localFetch).toBe(104);
|
|
96
|
+
for (let i = 0; i < 105; i++) s = R.setRecords('a', [], false, i)(s);
|
|
97
|
+
expect(s.queries.get('a')!.telemetry.phaseSamples.localFetch).toHaveLength(100);
|
|
98
|
+
for (let i = 0; i < 105; i++) s = R.recordIngest('a', i)(s);
|
|
99
|
+
expect(s.queries.get('a')!.telemetry.materializationSamples).toHaveLength(100);
|
|
100
|
+
expect(s.queries.get('a')!.telemetry.lastIngestLatencyMs).toBe(104);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('telemetry helpers', () => {
|
|
104
|
+
let s = buildState([e('a')]);
|
|
105
|
+
s = R.stampUpdated('a', 5)(s);
|
|
106
|
+
s = R.recordError('a')(s);
|
|
107
|
+
s = R.setRegistrationTimings('a', { parseMs: 1, planMs: 2, snapshotMs: 3, wallMs: 4 })(s);
|
|
108
|
+
s = R.bumpRegisterAttempts('a')(s);
|
|
109
|
+
s = R.bumpRegisterAttempts('a')(s);
|
|
110
|
+
s = R.stampHeartbeat(['a', 'missing'], 9)(s);
|
|
111
|
+
s = R.stampPolled(['a'], 11)(s);
|
|
112
|
+
const en = s.queries.get('a')!;
|
|
113
|
+
expect(en.telemetry.lastUpdatedAt).toBe(5);
|
|
114
|
+
expect(en.telemetry.errorCount).toBe(1);
|
|
115
|
+
expect(en.telemetry.registrationTimings.wallMs).toBe(4);
|
|
116
|
+
expect(en.registerAttempts).toBe(2);
|
|
117
|
+
expect(en.lastHeartbeatAt).toBe(9);
|
|
118
|
+
expect(en.lastPolledAt).toBe(11);
|
|
119
|
+
const reset = R.resetRegisterAttempts('a')(s);
|
|
120
|
+
expect(reset.queries.get('a')!.registerAttempts).toBe(0);
|
|
121
|
+
expect(R.resetRegisterAttempts('a')(reset)).toBe(reset);
|
|
122
|
+
const lost = R.setViewLost('a', 2, 900)(reset);
|
|
123
|
+
expect(lost.queries.get('a')).toMatchObject({ viewLostCount: 2, viewLostRetryAt: 900 });
|
|
124
|
+
expect(R.setViewLost('a', 2, 900)(lost)).toBe(lost);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('subscribe / unsubscribe track the eviction clock', () => {
|
|
128
|
+
let s = buildState([e('a')]);
|
|
129
|
+
s = R.subscribe('a')(s);
|
|
130
|
+
s = R.subscribe('a')(s);
|
|
131
|
+
expect(s.queries.get('a')!.subscribers).toBe(2);
|
|
132
|
+
s = R.unsubscribe('a', 100)(s);
|
|
133
|
+
expect(s.queries.get('a')!.lastSubscriberLeftAt).toBeNull();
|
|
134
|
+
s = R.unsubscribe('a', 200)(s);
|
|
135
|
+
expect(s.queries.get('a')!.lastSubscriberLeftAt).toBe(200);
|
|
136
|
+
s = R.unsubscribe('a', 300)(s);
|
|
137
|
+
expect(s.queries.get('a')!.subscribers).toBe(0);
|
|
138
|
+
s = R.subscribe('a')(s);
|
|
139
|
+
expect(s.queries.get('a')!.lastSubscriberLeftAt).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('registering / reread bookkeeping', () => {
|
|
144
|
+
it('tracks in-flight local registrations and reread attempts; removeQuery clears rereads', () => {
|
|
145
|
+
let s = buildState([e('a')]);
|
|
146
|
+
s = R.beginRegistering('x')(s);
|
|
147
|
+
expect(s.registering.has('x')).toBe(true);
|
|
148
|
+
s = R.endRegistering('x')(s);
|
|
149
|
+
expect(s.registering.size).toBe(0);
|
|
150
|
+
expect(R.endRegistering('x')(s)).toBe(s);
|
|
151
|
+
s = R.setMembershipReread('a', 1)(s);
|
|
152
|
+
expect(s.membershipReread.get('a')).toBe(1);
|
|
153
|
+
expect(R.setMembershipReread('zzz', null)(s)).toBe(s);
|
|
154
|
+
const cleared = R.removeQuery('a')(s);
|
|
155
|
+
expect(cleared.membershipReread.size).toBe(0);
|
|
156
|
+
s = R.setMembershipReread('a', null)(s);
|
|
157
|
+
expect(s.membershipReread.size).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('dirt reducers', () => {
|
|
162
|
+
it('markDirty / clearDirty / markTableDirty', () => {
|
|
163
|
+
const s0 = buildState([e('a', { def: { tableName: 't1' } }), e('b', { def: { tableName: 't2' } })]);
|
|
164
|
+
expect(R.markDirty([])(s0)).toBe(s0);
|
|
165
|
+
const s1 = R.markTableDirty('t1')(s0);
|
|
166
|
+
expect([...s1.dirty]).toEqual(['a']);
|
|
167
|
+
const s2 = R.clearDirty('a')(s1);
|
|
168
|
+
expect(s2.dirty.size).toBe(0);
|
|
169
|
+
expect(R.clearDirty('a')(s2)).toBe(s2);
|
|
170
|
+
});
|
|
171
|
+
it('membership dirt only for known hashes', () => {
|
|
172
|
+
const s0 = buildState([e('a')]);
|
|
173
|
+
expect(R.markMembershipDirty(['nope'])(s0)).toBe(s0);
|
|
174
|
+
const s1 = R.markMembershipDirty(['a', 'nope'])(s0);
|
|
175
|
+
expect([...s1.membershipDirty]).toEqual(['a']);
|
|
176
|
+
expect(R.clearMembershipDirty(['a'])(s1).membershipDirty.size).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('versions', () => {
|
|
181
|
+
it('setVersions dirties queries naming the id, ignores unchanged, deleteVersions removes', () => {
|
|
182
|
+
const s0 = buildState([
|
|
183
|
+
e('a', { remoteArray: [['thing:1', 1]] }),
|
|
184
|
+
e('b', { localArray: [['thing:2', 1]] }),
|
|
185
|
+
e('c'),
|
|
186
|
+
// A related row (a thread's author): a new body must re-render the list.
|
|
187
|
+
e('d', { subqueryRemoteArray: [['user:1', 1]] }),
|
|
188
|
+
]);
|
|
189
|
+
expect(R.setVersions([])(s0)).toBe(s0);
|
|
190
|
+
const s1 = R.setVersions([['thing:1', 1], ['thing:2', 1], ['thing:9', 1], ['user:1', 2]])(s0);
|
|
191
|
+
expect([...s1.dirty].sort()).toEqual(['a', 'b', 'd']);
|
|
192
|
+
expect(R.setVersions([['thing:1', 1]])(s1)).toBe(s1);
|
|
193
|
+
const s2 = R.deleteVersions(['thing:9', 'nope'])(s1);
|
|
194
|
+
expect(s2.versions.has('thing:9')).toBe(false);
|
|
195
|
+
expect(R.deleteVersions(['nope'])(s2)).toBe(s2);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('outbox', () => {
|
|
200
|
+
it('push / ack / bump / remove / replace dirty the table', () => {
|
|
201
|
+
const s0 = buildState([e('a', { def: { tableName: 'thing' } })]);
|
|
202
|
+
const s1 = R.outboxPush(buildOutboxItem({ id: 'm1' }))(s0);
|
|
203
|
+
expect(s1.dirty.has('a')).toBe(true);
|
|
204
|
+
const s2 = R.outboxAck('m1', 50)({ ...s1, dirty: new Set() });
|
|
205
|
+
expect(s2.outbox[0]).toMatchObject({ status: 'acked', ackedAt: 50 });
|
|
206
|
+
expect(s2.dirty.size).toBe(0);
|
|
207
|
+
expect(R.outboxAck('nope', 1)(s2)).toBe(s2);
|
|
208
|
+
const s3 = R.outboxBumpAttempts('m1')(s2);
|
|
209
|
+
expect(s3.outbox[0].attempts).toBe(1);
|
|
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);
|
|
214
|
+
const s4 = R.outboxRemove('m1')(s3);
|
|
215
|
+
expect(s4.outbox).toEqual([]);
|
|
216
|
+
expect(s4.dirty.has('a')).toBe(true);
|
|
217
|
+
expect(R.outboxRemove('m1')(s4)).toBe(s4);
|
|
218
|
+
const s5 = R.outboxReplace([buildOutboxItem({ id: 'x', table: 'thing' })])({ ...s4, dirty: new Set() });
|
|
219
|
+
expect(s5.outbox).toHaveLength(1);
|
|
220
|
+
expect(s5.dirty.has('a')).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
it('outboxPruneAcked drops only expired acked items', () => {
|
|
223
|
+
const s0 = buildState(
|
|
224
|
+
[e('a', { def: { tableName: 'thing' } })],
|
|
225
|
+
R.outboxReplace([
|
|
226
|
+
buildOutboxItem({ id: 'old', status: 'acked', ackedAt: 0 }),
|
|
227
|
+
buildOutboxItem({ id: 'fresh', status: 'acked', ackedAt: 90 }),
|
|
228
|
+
buildOutboxItem({ id: 'pending' }),
|
|
229
|
+
])
|
|
230
|
+
);
|
|
231
|
+
const s1 = R.outboxPruneAcked(100, 50)({ ...s0, dirty: new Set() });
|
|
232
|
+
expect(s1.outbox.map((i) => i.id)).toEqual(['fresh', 'pending']);
|
|
233
|
+
expect(s1.dirty.has('a')).toBe(true);
|
|
234
|
+
expect(R.outboxPruneAcked(100, 50)(s1)).toBe(s1);
|
|
235
|
+
});
|
|
236
|
+
it('pending writes merge per key and clear', () => {
|
|
237
|
+
const w = { key: 'k', table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: { a: 0 }, firstAt: 1, mirrorId: null };
|
|
238
|
+
let s = R.mergePendingWrite(w)(buildState());
|
|
239
|
+
s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9, mirrorId: '_00_pending_writes:m' })(s);
|
|
240
|
+
expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 }, mirrorId: '_00_pending_writes:m' });
|
|
241
|
+
s = R.mergePendingWrite({ ...w, mirrorId: '_00_pending_writes:other' })(s);
|
|
242
|
+
expect(s.pendingWrites.get('k')!.mirrorId).toBe('_00_pending_writes:m');
|
|
243
|
+
s = R.clearPendingWrite('k')(s);
|
|
244
|
+
expect(s.pendingWrites.size).toBe(0);
|
|
245
|
+
expect(R.clearPendingWrite('k')(s)).toBe(s);
|
|
246
|
+
});
|
|
247
|
+
it('setFailedCount', () => {
|
|
248
|
+
const s0 = buildState();
|
|
249
|
+
const s1 = R.setFailedCount(2)(s0);
|
|
250
|
+
expect(s1.failedCount).toBe(2);
|
|
251
|
+
expect(R.setFailedCount(2)(s1)).toBe(s1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('bucket switch reducers', () => {
|
|
256
|
+
it('rebindQuery keeps the hash, swaps the id and sync state, dirties; clearBucketState wipes per-bucket slices', () => {
|
|
257
|
+
const s0 = buildState(
|
|
258
|
+
[e('a', { lifecycle: { phase: 'live', remote: 'registered', notified: true }, remoteArray: [['t:1', 1]], records: [{ id: 't:1' }], serverState: 'ready', registerAttempts: 2 })],
|
|
259
|
+
R.setVersions([['t:1', 1]]),
|
|
260
|
+
R.outboxReplace([buildOutboxItem()])
|
|
261
|
+
);
|
|
262
|
+
const id = { table: '_00_query', id: 'new' } as any;
|
|
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() });
|
|
264
|
+
const en = s1.queries.get('a')!;
|
|
265
|
+
expect(en.def.id).toBe(id);
|
|
266
|
+
expect(en.lifecycle.phase).toBe('cached');
|
|
267
|
+
expect(en.remoteArray).toEqual([['t:2', 1]]);
|
|
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' }]);
|
|
271
|
+
expect(en.serverState).toBeNull();
|
|
272
|
+
expect(en.registerAttempts).toBe(0);
|
|
273
|
+
expect(s1.dirty.has('a')).toBe(true);
|
|
274
|
+
expect(R.rebindQuery('zz', { id, lifecycle: en.lifecycle, remoteArray: [], localArray: [] })(s1)).toBe(s1);
|
|
275
|
+
const s2 = R.clearBucketState()(s1);
|
|
276
|
+
expect(s2.versions.size).toBe(0);
|
|
277
|
+
expect(s2.outbox).toEqual([]);
|
|
278
|
+
expect(s2.dirty.size).toBe(0);
|
|
279
|
+
expect(s2.primed).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('identity / connection / compose', () => {
|
|
284
|
+
it('sets fields and short-circuits on no change', () => {
|
|
285
|
+
const s0 = buildState();
|
|
286
|
+
const s1 = R.setIdentity({ sessionId: 's', userId: 'u', bucketId: 'b' })(s0);
|
|
287
|
+
expect(s1).toMatchObject({ sessionId: 's', userId: 'u', bucketId: 'b' });
|
|
288
|
+
const s2 = R.setTabRole('leader')(s1);
|
|
289
|
+
expect(R.setTabRole('leader')(s2)).toBe(s2);
|
|
290
|
+
const s3 = R.setConnection('connected')(s2);
|
|
291
|
+
expect(s3.sync.health.connection).toBe('connected');
|
|
292
|
+
expect(R.setConnection('connected')(s3)).toBe(s3);
|
|
293
|
+
const s4 = R.setHealth({ ...s3.sync.health, status: 'degraded' })(s3);
|
|
294
|
+
expect(s4.sync.health.status).toBe('degraded');
|
|
295
|
+
const s5 = R.patchSync({ pollIdleStreak: 4 })(s4);
|
|
296
|
+
expect(s5.sync.pollIdleStreak).toBe(4);
|
|
297
|
+
const s6 = R.compose(R.setFailedCount(1), R.setFailedCount(3))(s5);
|
|
298
|
+
expect(s6.failedCount).toBe(3);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('durable view index', () => {
|
|
303
|
+
it('commit and children keep a view key in step with its `_00_view` row; cold children are not indexed', () => {
|
|
304
|
+
let s = buildState([e('a', { def: { viewKey: 'va' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })]);
|
|
305
|
+
s = R.setSubqueryRemoteArray('a', [['c:1', 1], ['c:2', 1]])(s);
|
|
306
|
+
expect(s.views.size).toBe(0);
|
|
307
|
+
s = R.commitMembership('a', [['t:1', 1], ['c:1', 1]], true)(s);
|
|
308
|
+
expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:2']);
|
|
309
|
+
s = R.setSubqueryRemoteArray('a', [['c:3', 1]])(s);
|
|
310
|
+
expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:3']);
|
|
311
|
+
expect(R.setSubqueryRemoteArray('zz', [['c:9', 1]])(s)).toBe(s);
|
|
312
|
+
expect(R.clearBucketState()(s).views.size).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
it('a reload takes the store, except entries of queries in state', () => {
|
|
315
|
+
const s = R.compose(R.commitMembership('a', [['t:new', 1]], true))(buildState([e('a', { def: { viewKey: 'va' } }), e('b', { def: { viewKey: 'vb' } })]));
|
|
316
|
+
const reloaded = R.reloadViews(new Map([['va', ['t:old']], ['vb', ['t:b']], ['vc', ['t:c']]]))(s);
|
|
317
|
+
expect(reloaded.views).toEqual(new Map([['va', ['t:new']], ['vb', ['t:b']], ['vc', ['t:c']]]));
|
|
318
|
+
});
|
|
319
|
+
});
|