@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- 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 +373 -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 +1857 -38
- package/dist/worker-edges.js +480 -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 +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -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 +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -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 +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -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 +167 -0
- package/src/kernel/events.ts +57 -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/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 +476 -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 +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -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 +746 -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/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 +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -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 +527 -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 +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -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 +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -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 +178 -0
- package/src/sync/live.saga.ts +107 -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 +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -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,188 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as S from './selectors';
|
|
3
|
+
import * as R from './reducers';
|
|
4
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
|
+
|
|
6
|
+
const e = (hash: string, over: Parameters<typeof buildEntry>[0] = {}) => buildEntry({ ...over, def: { hash, ...over.def } });
|
|
7
|
+
|
|
8
|
+
describe('basic lookups', () => {
|
|
9
|
+
it('queryByHash / activeHashes / hashesForTable / queryStatus', () => {
|
|
10
|
+
const s = buildState([e('a', { def: { tableName: 't1' } }), e('b', { def: { tableName: 't2' } })]);
|
|
11
|
+
expect(S.queryByHash(s, 'a')?.def.hash).toBe('a');
|
|
12
|
+
expect(S.activeHashes(s)).toEqual(['a', 'b']);
|
|
13
|
+
expect(S.hashesForTable(s, 't2')).toEqual(['b']);
|
|
14
|
+
expect(S.queryStatus(s, 'a')).toBe('idle');
|
|
15
|
+
expect(S.queryStatus(s, 'zz')).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('overlay and outbox counts', () => {
|
|
20
|
+
it('derives writes/deletes from pending and acked items', () => {
|
|
21
|
+
const s = buildState(
|
|
22
|
+
[],
|
|
23
|
+
R.outboxReplace([
|
|
24
|
+
buildOutboxItem({ id: '1', type: 'create', recordId: 'thing:1' }),
|
|
25
|
+
buildOutboxItem({ id: '2', type: 'update', recordId: 'thing:2', status: 'acked', ackedAt: 1 }),
|
|
26
|
+
buildOutboxItem({ id: '3', type: 'delete', recordId: 'thing:3' }),
|
|
27
|
+
])
|
|
28
|
+
);
|
|
29
|
+
const o = S.overlay(s);
|
|
30
|
+
expect([...o.writes].sort()).toEqual(['thing:1', 'thing:2']);
|
|
31
|
+
expect([...o.deletes]).toEqual(['thing:3']);
|
|
32
|
+
expect([...S.pendingDeleteIds(s)]).toEqual(['thing:3']);
|
|
33
|
+
expect(S.hasAckedWrites(s)).toBe(true);
|
|
34
|
+
expect(S.pendingMutationCount(s)).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('needed / planFetch / settled', () => {
|
|
39
|
+
const live = { phase: 'live' } as const;
|
|
40
|
+
it('needed compares membership versions against local versions and skips pending deletes', () => {
|
|
41
|
+
const s = buildState(
|
|
42
|
+
[e('a', { lifecycle: live, remoteArray: [['thing:1', 2], ['thing:2', 1], ['thing:3', 1]] })],
|
|
43
|
+
R.setVersions([['thing:1', 2], ['thing:2', 0]]),
|
|
44
|
+
R.outboxReplace([buildOutboxItem({ type: 'delete', recordId: 'thing:3' })])
|
|
45
|
+
);
|
|
46
|
+
expect(S.needed(s, 'a')).toEqual([['thing:2', 1]]);
|
|
47
|
+
expect(S.needed(s, 'missing')).toEqual([]);
|
|
48
|
+
const cached = buildState([e('c', { lifecycle: { phase: 'cached' }, remoteArray: [['thing:9', 1]] })]);
|
|
49
|
+
expect(S.needed(cached, 'c')).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
it('planFetch dedupes across queries (highest version wins), includes subquery children, chunks', () => {
|
|
52
|
+
const s = buildState([
|
|
53
|
+
e('a', { lifecycle: live, remoteArray: [['thing:1', 1], ['thing:2', 3]] }),
|
|
54
|
+
e('b', { lifecycle: live, remoteArray: [['thing:2', 1], ['thing:3', 1]], subqueryRemoteArray: [['child:1', 2]] }),
|
|
55
|
+
e('c', { lifecycle: live, subqueryRemoteArray: [['child:1', 1], ['child:2', 1]] }),
|
|
56
|
+
e('d', { lifecycle: { phase: 'cached' }, remoteArray: [['thing:9', 1]] }),
|
|
57
|
+
]);
|
|
58
|
+
const plan = S.planFetch(s, 2);
|
|
59
|
+
expect(plan.hashes).toEqual(['a', 'b']);
|
|
60
|
+
expect(plan.chunks).toEqual([['thing:1', 'thing:2'], ['thing:3', 'child:1'], ['child:2']]);
|
|
61
|
+
expect(plan.versions.get('thing:2')).toBe(3);
|
|
62
|
+
expect(plan.versions.get('child:1')).toBe(2);
|
|
63
|
+
expect(S.planFetch(buildState()).chunks).toEqual([]);
|
|
64
|
+
expect(S.neededChildren(s, 'missing')).toEqual([]);
|
|
65
|
+
expect(S.neededChildren(R.setVersions([['child:1', 2]])(s), 'b')).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
it('settled requires live, complete, clean, notified', () => {
|
|
68
|
+
const base = e('a', { lifecycle: { ...live, notified: true }, remoteArray: [['thing:1', 1]] });
|
|
69
|
+
const s = { ...buildState([base], R.setVersions([['thing:1', 1]])), dirty: new Set<string>() };
|
|
70
|
+
expect(S.settled(s, 'a')).toBe(true);
|
|
71
|
+
expect(S.settled(R.markDirty(['a'])(s), 'a')).toBe(false);
|
|
72
|
+
expect(S.settled(R.setVersions([['thing:1', 0]])(s), 'a')).toBe(false);
|
|
73
|
+
expect(S.settled(buildState([e('a', { lifecycle: { phase: 'cached', notified: true } })]), 'a')).toBe(false);
|
|
74
|
+
expect(S.settled(buildState([e('a', { lifecycle: live })]), 'a')).toBe(false);
|
|
75
|
+
expect(S.settled(s, 'missing')).toBe(false);
|
|
76
|
+
expect(S.settleFailed(s, 'missing')).toBe(true);
|
|
77
|
+
expect(S.settleFailed(s, 'a')).toBe(false);
|
|
78
|
+
expect(S.settleFailed(R.applyLifecycle('a', { type: 'remote-failed' })(s), 'a')).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it('queryStatus stays fetching until a live query is settled', () => {
|
|
81
|
+
const base = e('a', { lifecycle: { ...live, notified: true }, remoteArray: [['thing:1', 1]] });
|
|
82
|
+
const s = { ...buildState([base], R.setVersions([['thing:1', 1]])), dirty: new Set<string>() };
|
|
83
|
+
expect(S.queryStatus(s, 'a')).toBe('idle');
|
|
84
|
+
// Membership in, body still downloading (registration already ended its cycle).
|
|
85
|
+
expect(S.queryStatus(R.setVersions([['thing:1', 0]])(s), 'a')).toBe('fetching');
|
|
86
|
+
// Body landed, not yet shown to subscribers.
|
|
87
|
+
expect(S.queryStatus(R.markDirty(['a'])(s), 'a')).toBe('fetching');
|
|
88
|
+
// A cold query with no fetch in flight has nothing to wait for.
|
|
89
|
+
expect(S.queryStatus(buildState([e('c', { lifecycle: { phase: 'cold' } })]), 'c')).toBe('idle');
|
|
90
|
+
expect(S.queryStatus(R.applyLifecycle('a', { type: 'fetch-begin' })(s), 'a')).toBe('fetching');
|
|
91
|
+
expect(S.queryStatus(s, 'missing')).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
it('fetchingQueryCount counts entries with fetch depth', () => {
|
|
94
|
+
const s = buildState([e('a', { lifecycle: { fetchDepth: 2 } }), e('b')]);
|
|
95
|
+
expect(S.fetchingQueryCount(s)).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
it('fetchingQueryCount leaves out background re-verification', () => {
|
|
98
|
+
const s = buildState([
|
|
99
|
+
e('verifying', { lifecycle: { phase: 'cached', fetchDepth: 1, backgroundDepth: 1 } }),
|
|
100
|
+
e('downloading', { lifecycle: { phase: 'live', fetchDepth: 2, backgroundDepth: 1 } }),
|
|
101
|
+
]);
|
|
102
|
+
expect(S.fetchingQueryCount(s)).toBe(1);
|
|
103
|
+
expect(S.queryStatus(s, 'verifying')).toBe('fetching');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('registration / eviction / ttl', () => {
|
|
108
|
+
it('desiredRegistrations lists unregistered queries', () => {
|
|
109
|
+
const s = buildState([e('a'), e('b', { lifecycle: { remote: 'registered' } }), e('c', { lifecycle: { remote: 'registering' } })]);
|
|
110
|
+
expect(S.desiredRegistrations(s)).toEqual(['a']);
|
|
111
|
+
});
|
|
112
|
+
it('evictable respects subscribers and ttl', () => {
|
|
113
|
+
const s = buildState([
|
|
114
|
+
e('gone', { lastSubscriberLeftAt: 0, def: { ttlMs: 100 } }),
|
|
115
|
+
e('fresh', { lastSubscriberLeftAt: 950, def: { ttlMs: 100 } }),
|
|
116
|
+
e('watched', { subscribers: 1, lastSubscriberLeftAt: 0, def: { ttlMs: 100 } }),
|
|
117
|
+
e('never', { def: { ttlMs: 100 } }),
|
|
118
|
+
]);
|
|
119
|
+
expect(S.evictable(s, 1000)).toEqual(['gone']);
|
|
120
|
+
});
|
|
121
|
+
it('shortestTtlMs', () => {
|
|
122
|
+
expect(S.shortestTtlMs(buildState())).toBeNull();
|
|
123
|
+
expect(S.shortestTtlMs(buildState([e('a', { def: { ttlMs: 500 } }), e('b', { def: { ttlMs: 100 } })]))).toBe(100);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('toQueryState', () => {
|
|
128
|
+
it('projects the legacy shape with derived flags', () => {
|
|
129
|
+
const entry = e('a', {
|
|
130
|
+
lifecycle: { phase: 'view-lost', fetchDepth: 1, notified: true },
|
|
131
|
+
remoteArray: [['thing:1', 1]],
|
|
132
|
+
subqueryRemoteArray: [['child:1', 1]],
|
|
133
|
+
records: [{ id: 'thing:1' }],
|
|
134
|
+
serverState: 'ready',
|
|
135
|
+
lastHeartbeatAt: 123,
|
|
136
|
+
});
|
|
137
|
+
const qs = S.toQueryState(entry);
|
|
138
|
+
expect(qs.config.membershipKnown).toBe(true);
|
|
139
|
+
expect(qs.config.membershipKey).toBe('view-a');
|
|
140
|
+
expect(qs.viewLost).toBe(true);
|
|
141
|
+
expect(qs.serverMembership).toBe(true);
|
|
142
|
+
expect(qs.status).toBe('fetching');
|
|
143
|
+
expect(qs.syncNotified).toBe(true);
|
|
144
|
+
expect(qs.config.subqueryRemoteArray).toEqual([['child:1', 1]]);
|
|
145
|
+
expect(qs.config.lastActiveAt.getTime()).toBe(123);
|
|
146
|
+
expect(qs.records).toEqual([{ id: 'thing:1' }]);
|
|
147
|
+
const cold = S.toQueryState(e('b'));
|
|
148
|
+
expect(cold.config.membershipKnown).toBe(false);
|
|
149
|
+
expect(cold.config.subqueryRemoteArray).toBeUndefined();
|
|
150
|
+
expect(cold.lastHeartbeatAt).toBeUndefined();
|
|
151
|
+
expect(cold.config.lastActiveAt.getTime()).toBe(1_700_000_000_000);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('phaseTimings', () => {
|
|
156
|
+
it('summarizes every phase, with nulls before the first sample', () => {
|
|
157
|
+
const cold = S.phaseTimings(e('a'));
|
|
158
|
+
expect(cold.ssp).toEqual({ lastMs: null, p50: null, p90: null, p99: null, count: 0 });
|
|
159
|
+
expect(cold.localFetch.count).toBe(0);
|
|
160
|
+
let s = buildState([e('a')]);
|
|
161
|
+
for (const ms of [5, 1, 9, 3]) s = R.recordIngest('a', ms)(s);
|
|
162
|
+
s = R.recordPhase('a', 'remoteFetch', 7)(s);
|
|
163
|
+
s = R.recordError('a')(s);
|
|
164
|
+
const t = S.phaseTimings(s.queries.get('a')!);
|
|
165
|
+
expect(t.ssp).toEqual({ lastMs: 3, p50: 5, p90: 9, p99: 9, count: 4 });
|
|
166
|
+
expect(t.remoteFetch).toEqual({ lastMs: 7, p50: 7, p90: 7, p99: 7, count: 1 });
|
|
167
|
+
expect(t.errorCount).toBe(1);
|
|
168
|
+
expect(t.registration.parseMs).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('view index selectors', () => {
|
|
173
|
+
it('vouched is the union of every view row, memoized on the index', () => {
|
|
174
|
+
const s = R.reloadViews(new Map([['v1', ['t:1', 't:2']], ['v2', ['t:2', 'c:1']]]))(buildState());
|
|
175
|
+
expect([...S.vouched(s)]).toEqual(['t:1', 't:2', 'c:1']);
|
|
176
|
+
expect(S.vouched({ ...s, dirty: new Set(['x']) })).toBe(S.vouched(s));
|
|
177
|
+
});
|
|
178
|
+
it('retained: vouched, held in memory (members, children), in the outbox or a debounced write, or bookkeeping', () => {
|
|
179
|
+
const s = R.compose(
|
|
180
|
+
R.reloadViews(new Map([['v', ['t:vouched']]])),
|
|
181
|
+
R.outboxReplace([buildOutboxItem({ recordId: 't:outbox' })]),
|
|
182
|
+
(st) => ({ ...st, pendingWrites: new Map([['k', { key: 'k', table: 't', recordId: 't:debounced', data: {}, before: null, firstAt: 0, mirrorId: null }]]) })
|
|
183
|
+
)(buildState([e('a', { remoteArray: [['t:member', 1]], subqueryRemoteArray: [['t:child', 1]] })]));
|
|
184
|
+
const keep = S.retained(s);
|
|
185
|
+
for (const id of ['t:vouched', 't:member', 't:child', 't:outbox', 't:debounced', '_00_view:x']) expect(keep(id)).toBe(true);
|
|
186
|
+
expect(keep('t:orphan')).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { PhaseStat, QueryHash, QueryState, QueryStatus, QueryTimings, RecordVersionArray } from '../types';
|
|
2
|
+
import type { ClientState, QueryEntry } from './client-state';
|
|
3
|
+
import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching } from './lifecycle';
|
|
4
|
+
import { FETCH_CHUNK } from '../kernel/constants';
|
|
5
|
+
|
|
6
|
+
/** Pure reads over `ClientState`. Sagas use them through `state.read`. */
|
|
7
|
+
|
|
8
|
+
export const queryByHash = (s: ClientState, hash: QueryHash): QueryEntry | undefined => s.queries.get(hash);
|
|
9
|
+
|
|
10
|
+
export const activeHashes = (s: ClientState): QueryHash[] => [...s.queries.keys()];
|
|
11
|
+
|
|
12
|
+
export const hashesForTable = (s: ClientState, table: string): QueryHash[] =>
|
|
13
|
+
[...s.queries].filter(([, e]) => e.def.tableName === table).map(([h]) => h);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The status subscribers see. `fetching` while a fetch cycle is in flight,
|
|
17
|
+
* and also while a live query is not yet `settled`: the server's membership
|
|
18
|
+
* is in but its rows are still downloading or not yet shown. Registration
|
|
19
|
+
* ends its cycle as soon as membership is applied and the body fetch starts
|
|
20
|
+
* its own a moment later, so the fetch count alone read `idle` in between,
|
|
21
|
+
* and a binding that settles on "membership known and idle" told the app a
|
|
22
|
+
* row did not exist while it was on its way (a by-id screen showed "not
|
|
23
|
+
* found" for a moment, or for as long as the bodies took).
|
|
24
|
+
*/
|
|
25
|
+
export const queryStatus = (s: ClientState, hash: QueryHash): QueryStatus | undefined => {
|
|
26
|
+
const e = s.queries.get(hash);
|
|
27
|
+
if (!e) return undefined;
|
|
28
|
+
if (deriveStatus(e.lifecycle) === 'fetching') return 'fetching';
|
|
29
|
+
return e.lifecycle.phase === 'live' && !settled(s, hash) ? 'fetching' : 'idle';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface Overlay {
|
|
33
|
+
/** Ids with an unsynced or just-acked create/update. */
|
|
34
|
+
readonly writes: ReadonlySet<string>;
|
|
35
|
+
/** Ids with an unsynced or just-acked delete. */
|
|
36
|
+
readonly deletes: ReadonlySet<string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The optimistic overlay, derived from the outbox (pending AND acked items). */
|
|
40
|
+
export function overlay(s: ClientState): Overlay {
|
|
41
|
+
const writes = new Set<string>();
|
|
42
|
+
const deletes = new Set<string>();
|
|
43
|
+
for (const item of s.outbox) {
|
|
44
|
+
if (item.type === 'delete') deletes.add(item.recordId);
|
|
45
|
+
else writes.add(item.recordId);
|
|
46
|
+
}
|
|
47
|
+
return { writes, deletes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const vouchedCache = new WeakMap<ClientState['views'], ReadonlySet<string>>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every id some durable `_00_view` row vouches for. Memoized on the index,
|
|
54
|
+
* which only changes when a view row does.
|
|
55
|
+
*/
|
|
56
|
+
export function vouched(s: ClientState): ReadonlySet<string> {
|
|
57
|
+
const hit = vouchedCache.get(s.views);
|
|
58
|
+
if (hit) return hit;
|
|
59
|
+
const out = new Set<string>();
|
|
60
|
+
for (const ids of s.views.values()) for (const id of ids) out.add(id);
|
|
61
|
+
vouchedCache.set(s.views, out);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bodies the GC must keep: `_00_` bookkeeping, anything a durable view
|
|
67
|
+
* vouches for, anything a query in memory names (members or children), and
|
|
68
|
+
* anything a write is still in flight for.
|
|
69
|
+
*/
|
|
70
|
+
export function retained(s: ClientState): (id: string) => boolean {
|
|
71
|
+
const held = new Set<string>();
|
|
72
|
+
for (const e of s.queries.values()) {
|
|
73
|
+
for (const [id] of e.remoteArray) held.add(id);
|
|
74
|
+
for (const [id] of e.subqueryRemoteArray) held.add(id);
|
|
75
|
+
}
|
|
76
|
+
for (const item of s.outbox) held.add(item.recordId);
|
|
77
|
+
for (const write of s.pendingWrites.values()) held.add(write.recordId);
|
|
78
|
+
const known = vouched(s);
|
|
79
|
+
return (id) => id.startsWith('_00_') || known.has(id) || held.has(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ids something live still needs: a mounted query's membership or subquery
|
|
84
|
+
* children, or a local write not yet settled. Unlike `retained`, durable view
|
|
85
|
+
* rows of queries nobody runs right now do not count: a row the server just
|
|
86
|
+
* took out of a live query must stop showing even if an old view named it.
|
|
87
|
+
*/
|
|
88
|
+
export function activelyHeld(s: ClientState): ReadonlySet<string> {
|
|
89
|
+
const held = new Set<string>();
|
|
90
|
+
for (const e of s.queries.values()) {
|
|
91
|
+
for (const [id] of e.remoteArray) held.add(id);
|
|
92
|
+
for (const [id] of e.subqueryRemoteArray) held.add(id);
|
|
93
|
+
}
|
|
94
|
+
for (const item of s.outbox) held.add(item.recordId);
|
|
95
|
+
for (const write of s.pendingWrites.values()) held.add(write.recordId);
|
|
96
|
+
return held;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ids of `before` that `after` no longer names. */
|
|
100
|
+
export function departedIds(before: RecordVersionArray, after: RecordVersionArray): string[] {
|
|
101
|
+
if (before.length === 0) return [];
|
|
102
|
+
const kept = new Set(after.map(([id]) => id));
|
|
103
|
+
return before.filter(([id]) => !kept.has(id)).map(([id]) => id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const pendingDeleteIds = (s: ClientState): ReadonlySet<string> =>
|
|
107
|
+
new Set(s.outbox.filter((i) => i.type === 'delete').map((i) => i.recordId));
|
|
108
|
+
|
|
109
|
+
export const hasAckedWrites = (s: ClientState): boolean => s.outbox.some((i) => i.status === 'acked');
|
|
110
|
+
|
|
111
|
+
export const pendingMutationCount = (s: ClientState): number =>
|
|
112
|
+
s.outbox.filter((i) => i.status === 'pending').length;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Queries with traffic the user is waiting on. A re-registration of a query
|
|
116
|
+
* that already shows rows (every mounted query after a reload) is left out:
|
|
117
|
+
* it only confirms what the cache painted, and any newer body it finds is
|
|
118
|
+
* fetched by `fetchRows`, which counts.
|
|
119
|
+
*/
|
|
120
|
+
export const fetchingQueryCount = (s: ClientState): number =>
|
|
121
|
+
[...s.queries.values()].filter((e) => isForegroundFetching(e.lifecycle)).length;
|
|
122
|
+
|
|
123
|
+
/** Membership entries whose body is missing or older locally. */
|
|
124
|
+
export function needed(s: ClientState, hash: QueryHash): RecordVersionArray {
|
|
125
|
+
const e = s.queries.get(hash);
|
|
126
|
+
if (!e || !hasServerMembership(e.lifecycle)) return [];
|
|
127
|
+
const deletes = pendingDeleteIds(s);
|
|
128
|
+
return e.remoteArray.filter(([id, v]) => !deletes.has(id) && (s.versions.get(id) ?? -1) < v);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Subquery child bodies missing or stale locally (never part of `settled`). */
|
|
132
|
+
export function neededChildren(s: ClientState, hash: QueryHash): RecordVersionArray {
|
|
133
|
+
const e = s.queries.get(hash);
|
|
134
|
+
if (!e || e.subqueryRemoteArray.length === 0) return [];
|
|
135
|
+
return e.subqueryRemoteArray.filter(([id, v]) => (s.versions.get(id) ?? -1) < v);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface FetchPlan {
|
|
139
|
+
/** Queries whose primary membership needs bodies (they flip to `fetching`). */
|
|
140
|
+
readonly hashes: QueryHash[];
|
|
141
|
+
readonly chunks: string[][];
|
|
142
|
+
/** Highest requested version per id, across every query naming it. */
|
|
143
|
+
readonly versions: ReadonlyMap<string, number>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cross-query, deduped, chunked list of ids to pull from the server. */
|
|
147
|
+
export function planFetch(s: ClientState, chunkSize = FETCH_CHUNK): FetchPlan {
|
|
148
|
+
const versions = new Map<string, number>();
|
|
149
|
+
const hashes: QueryHash[] = [];
|
|
150
|
+
const add = (pairs: RecordVersionArray) => {
|
|
151
|
+
for (const [id, v] of pairs) versions.set(id, Math.max(v, versions.get(id) ?? -1));
|
|
152
|
+
};
|
|
153
|
+
for (const hash of s.queries.keys()) {
|
|
154
|
+
const missing = needed(s, hash);
|
|
155
|
+
if (missing.length > 0) hashes.push(hash);
|
|
156
|
+
add(missing);
|
|
157
|
+
add(neededChildren(s, hash));
|
|
158
|
+
}
|
|
159
|
+
const all = [...versions.keys()];
|
|
160
|
+
const chunks: string[][] = [];
|
|
161
|
+
for (let i = 0; i < all.length; i += chunkSize) chunks.push(all.slice(i, i + chunkSize));
|
|
162
|
+
return { hashes, chunks, versions };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* "This query's rows are authoritative and complete": server membership
|
|
167
|
+
* accepted, every body present at its version, nothing waiting to be
|
|
168
|
+
* re-rendered, subscribers told at least once.
|
|
169
|
+
*/
|
|
170
|
+
export function settled(s: ClientState, hash: QueryHash): boolean {
|
|
171
|
+
const e = s.queries.get(hash);
|
|
172
|
+
if (!e || e.lifecycle.phase !== 'live') return false;
|
|
173
|
+
return needed(s, hash).length === 0 && !s.dirty.has(hash) && e.lifecycle.notified;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const settleFailed = (s: ClientState, hash: QueryHash): boolean => {
|
|
177
|
+
const e = s.queries.get(hash);
|
|
178
|
+
return !e || e.lifecycle.remote === 'failed';
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const desiredRegistrations = (s: ClientState): QueryHash[] =>
|
|
182
|
+
[...s.queries].filter(([, e]) => e.lifecycle.remote === 'unregistered').map(([h]) => h);
|
|
183
|
+
|
|
184
|
+
export const evictable = (s: ClientState, now: number): QueryHash[] =>
|
|
185
|
+
[...s.queries]
|
|
186
|
+
.filter(
|
|
187
|
+
([, e]) => e.subscribers === 0 && e.lastSubscriberLeftAt !== null && now - e.lastSubscriberLeftAt >= e.def.ttlMs
|
|
188
|
+
)
|
|
189
|
+
.map(([h]) => h);
|
|
190
|
+
|
|
191
|
+
export const shortestTtlMs = (s: ClientState): number | null => {
|
|
192
|
+
let min: number | null = null;
|
|
193
|
+
for (const e of s.queries.values()) min = min === null ? e.def.ttlMs : Math.min(min, e.def.ttlMs);
|
|
194
|
+
return min;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Legacy `QueryState` shape for DevTools and the public type surface. */
|
|
198
|
+
export function toQueryState(e: QueryEntry): QueryState {
|
|
199
|
+
const l = e.lifecycle;
|
|
200
|
+
return {
|
|
201
|
+
config: {
|
|
202
|
+
id: e.def.id,
|
|
203
|
+
surql: e.def.surql,
|
|
204
|
+
plan: e.def.plan,
|
|
205
|
+
params: { ...e.def.params },
|
|
206
|
+
localArray: [...e.localArray],
|
|
207
|
+
remoteArray: [...e.remoteArray],
|
|
208
|
+
subqueryRemoteArray: e.subqueryRemoteArray.length ? [...e.subqueryRemoteArray] : undefined,
|
|
209
|
+
membershipKnown: isAuthoritative(l),
|
|
210
|
+
membershipKey: e.def.viewKey,
|
|
211
|
+
ttl: e.def.ttl,
|
|
212
|
+
lastActiveAt: new Date(e.lastHeartbeatAt ?? e.def.createdAt),
|
|
213
|
+
tableName: e.def.tableName,
|
|
214
|
+
},
|
|
215
|
+
records: [...e.records] as Record<string, any>[],
|
|
216
|
+
serverMembership: hasServerMembership(l),
|
|
217
|
+
viewLost: l.phase === 'view-lost',
|
|
218
|
+
serverState: e.serverState,
|
|
219
|
+
syncNotified: l.notified,
|
|
220
|
+
ttlTimer: null,
|
|
221
|
+
ttlDurationMs: e.def.ttlMs,
|
|
222
|
+
lastHeartbeatAt: e.lastHeartbeatAt ?? undefined,
|
|
223
|
+
updateCount: e.telemetry.updateCount,
|
|
224
|
+
lastUpdatedAt: e.telemetry.lastUpdatedAt,
|
|
225
|
+
materializationSamples: [...e.telemetry.materializationSamples],
|
|
226
|
+
lastIngestLatencyMs: e.telemetry.lastIngestLatencyMs,
|
|
227
|
+
errorCount: e.telemetry.errorCount,
|
|
228
|
+
status: deriveStatus(l),
|
|
229
|
+
phaseSamples: Object.fromEntries(Object.entries(e.telemetry.phaseSamples).map(([k, v]) => [k, [...v]])),
|
|
230
|
+
phaseLast: { ...e.telemetry.phaseLast },
|
|
231
|
+
registrationTimings: e.telemetry.registrationTimings,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function phaseStatOf(samples: ReadonlyArray<number>, lastMs: number | null): PhaseStat {
|
|
236
|
+
if (samples.length === 0) return { lastMs, p50: null, p90: null, p99: null, count: 0 };
|
|
237
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
238
|
+
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
239
|
+
return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Per-query processing-time breakdown for DevTools and the MCP. */
|
|
243
|
+
export function phaseTimings(e: QueryEntry): QueryTimings {
|
|
244
|
+
const t = e.telemetry;
|
|
245
|
+
const stat = (phase: string) => phaseStatOf(t.phaseSamples[phase] ?? [], t.phaseLast[phase] ?? null);
|
|
246
|
+
return {
|
|
247
|
+
ssp: phaseStatOf(t.materializationSamples, t.lastIngestLatencyMs),
|
|
248
|
+
sspStoreApply: stat('sspStoreApply'),
|
|
249
|
+
sspCircuitStep: stat('sspCircuitStep'),
|
|
250
|
+
sspTransform: stat('sspTransform'),
|
|
251
|
+
localFetch: stat('localFetch'),
|
|
252
|
+
remoteFetch: stat('remoteFetch'),
|
|
253
|
+
frontend: stat('frontend'),
|
|
254
|
+
registration: t.registrationTimings,
|
|
255
|
+
updateCount: t.updateCount,
|
|
256
|
+
errorCount: t.errorCount,
|
|
257
|
+
};
|
|
258
|
+
}
|