@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14939 -2432
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -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 +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -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 +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -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.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -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 +161 -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 +478 -0
- package/src/live/index.ts +1013 -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 +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -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 +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -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 +285 -0
- package/src/mutation/push.saga.ts +217 -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 +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -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 +159 -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 +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -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 +199 -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 +146 -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 +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -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 +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -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 +246 -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 +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -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 +779 -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 +198 -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 +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -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 +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -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 +86 -0
- package/src/sync/connection.saga.ts +85 -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 +659 -0
- package/src/sync/policy.ts +502 -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 +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -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 +624 -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 +75 -9
- 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 +30 -18
- package/src/utils/utils.test.ts +2 -1
- 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,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
|
+
import * as R from '../state/reducers';
|
|
6
|
+
import { materialize, streamUpdate } from './materialize.saga';
|
|
7
|
+
|
|
8
|
+
describe('materialize', () => {
|
|
9
|
+
const plan = { table: 'thing' } as any;
|
|
10
|
+
it('no entry: nothing', async () => {
|
|
11
|
+
const out = await runPure(materialize('zz'), { state: buildState() });
|
|
12
|
+
expect(out.log.filter((e) => e.kind !== 'state.read')).toHaveLength(0);
|
|
13
|
+
});
|
|
14
|
+
const vouch = (...ids: string[]) => R.reloadViews(new Map([['other-view', ids]]));
|
|
15
|
+
it('cold: predicate scan through the plan; rows land, dirt clears, subscribers hear once', async () => {
|
|
16
|
+
const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })], vouch('thing:1')));
|
|
17
|
+
const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': (e: any) => (e.plan.ids ? [] : [{ id: 'thing:1' }]) } });
|
|
18
|
+
const en = out.state.queries.get('a')!;
|
|
19
|
+
expect(en.records).toEqual([{ id: 'thing:1' }]);
|
|
20
|
+
expect(en.lifecycle.notified).toBe(true);
|
|
21
|
+
expect(en.telemetry.updateCount).toBe(1);
|
|
22
|
+
expect(en.telemetry.lastUpdatedAt).toBe(1_700_000_000_000);
|
|
23
|
+
expect(out.state.dirty.has('a')).toBe(false);
|
|
24
|
+
expect(out.emitted).toEqual([{ type: 'query:records', hash: 'a', records: [{ id: 'thing:1' }] }]);
|
|
25
|
+
const again = await runPure(materialize('a'), { state: out.state, handlers: { 'local.select': () => [{ id: 'thing:1' }] } });
|
|
26
|
+
expect(again.emitted).toEqual([]);
|
|
27
|
+
expect(again.state.queries.get('a')!.telemetry.updateCount).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
it('cold: a body no view vouches for (an orphan) does not paint; vouched, optimistic, _00_ and id-less rows do', async () => {
|
|
30
|
+
const s = buildState(
|
|
31
|
+
[buildEntry({ def: { hash: 'a', plan } })],
|
|
32
|
+
vouch('thing:1', 'thing:4'),
|
|
33
|
+
R.outboxReplace([buildOutboxItem({ id: 'c', type: 'create', recordId: 'thing:3' })])
|
|
34
|
+
);
|
|
35
|
+
const scanned = [
|
|
36
|
+
{ id: 'thing:1' },
|
|
37
|
+
{ id: 'thing:2' },
|
|
38
|
+
{ id: 'thing:3' },
|
|
39
|
+
{ id: '_00_user_feature:f' },
|
|
40
|
+
{ name: 'no id' },
|
|
41
|
+
{ id: new RecordId('thing', '4') },
|
|
42
|
+
{ id: new RecordId('thing', '5') },
|
|
43
|
+
];
|
|
44
|
+
const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': () => scanned } });
|
|
45
|
+
expect(out.state.queries.get('a')!.records).toEqual([
|
|
46
|
+
{ id: 'thing:1' },
|
|
47
|
+
{ id: 'thing:3' },
|
|
48
|
+
{ id: '_00_user_feature:f' },
|
|
49
|
+
{ name: 'no id' },
|
|
50
|
+
{ id: new RecordId('thing', '4') },
|
|
51
|
+
]);
|
|
52
|
+
// Once the server has answered, membership decides and the index is not consulted.
|
|
53
|
+
const live = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1]] })]);
|
|
54
|
+
const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
55
|
+
expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:2' }]);
|
|
56
|
+
});
|
|
57
|
+
it('cold window renders the SSP local window (orphans dropped); live renders membership with the overlay', async () => {
|
|
58
|
+
const win = buildState(
|
|
59
|
+
[buildEntry({ def: { hash: 'w', plan: { ...plan, offset: 5 }, surql: 'SELECT * FROM thing LIMIT 5 START 5' }, localArray: [['thing:7', 1], ['thing:8', 1]] })],
|
|
60
|
+
vouch('thing:7')
|
|
61
|
+
);
|
|
62
|
+
const w = await runPure(materialize('w'), { state: win, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
63
|
+
expect(w.state.queries.get('w')!.records).toEqual([{ id: 'thing:7' }]);
|
|
64
|
+
const live = buildState(
|
|
65
|
+
[buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1], ['thing:1', 1]], localArray: [['thing:9', 1]] })],
|
|
66
|
+
R.outboxReplace([buildOutboxItem({ id: 'c', type: 'create', recordId: 'thing:9' }), buildOutboxItem({ id: 'd', type: 'delete', recordId: 'thing:2' })])
|
|
67
|
+
);
|
|
68
|
+
const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
69
|
+
expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:1' }, { id: 'thing:9' }]);
|
|
70
|
+
const ordered = buildState([buildEntry({ def: { hash: 'o', plan: { ...plan, orderBy: [['a', 'asc']] } }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1], ['thing:1', 1]] })]);
|
|
71
|
+
const o = await runPure(materialize('o'), { state: ordered, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
72
|
+
expect(o.state.queries.get('o')!.records).toEqual([{ id: 'thing:2' }, { id: 'thing:1' }]);
|
|
73
|
+
});
|
|
74
|
+
it('a failing read counts an error and clears dirt; an entry removed mid-read is left alone', async () => {
|
|
75
|
+
const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })]));
|
|
76
|
+
const fail = await runPure(materialize('a'), {
|
|
77
|
+
state: s,
|
|
78
|
+
handlers: {
|
|
79
|
+
'local.select': () => {
|
|
80
|
+
throw new Error('locked');
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
expect(fail.state.queries.get('a')!.telemetry.errorCount).toBe(1);
|
|
85
|
+
expect(fail.state.dirty.has('a')).toBe(false);
|
|
86
|
+
expect(fail.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
87
|
+
const gone = await runPure(materialize('a'), {
|
|
88
|
+
state: s,
|
|
89
|
+
handlers: {
|
|
90
|
+
'local.select': (_e, ctx) => {
|
|
91
|
+
ctx.state = R.removeQuery('a')(ctx.state);
|
|
92
|
+
return [];
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(gone.emitted).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('streamUpdate', () => {
|
|
101
|
+
it('takes the local id-set (dirtying the query) and records ingest timings; unknown hashes are ignored', async () => {
|
|
102
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
103
|
+
const out = await runPure(streamUpdate({ queryHash: 'a', localArray: [['thing:1', 1]], op: 'CREATE', materializationTimeMs: 4, storeApplyMs: 1, circuitStepMs: 2 }), { state: s });
|
|
104
|
+
const en = out.state.queries.get('a')!;
|
|
105
|
+
expect(en.localArray).toEqual([['thing:1', 1]]);
|
|
106
|
+
expect(out.state.dirty.has('a')).toBe(true);
|
|
107
|
+
expect(en.telemetry.lastIngestLatencyMs).toBe(4);
|
|
108
|
+
expect(en.telemetry.materializationSamples).toEqual([4]);
|
|
109
|
+
expect(en.telemetry.phaseLast).toEqual({ sspStoreApply: 1, sspCircuitStep: 2 });
|
|
110
|
+
const bare = await runPure(streamUpdate({ queryHash: 'a', localArray: [] }), { state: s });
|
|
111
|
+
expect(bare.state.queries.get('a')!.telemetry.lastIngestLatencyMs).toBeNull();
|
|
112
|
+
const unknown = await runPure(streamUpdate({ queryHash: 'zz', localArray: [] }), { state: s });
|
|
113
|
+
expect(unknown.log.filter((e) => e.kind === 'state.update')).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { QueryHash } from '../types';
|
|
2
|
+
import type { StreamUpdate } from '../services/stream-processor/index';
|
|
3
|
+
import type { Saga } from '../kernel/saga';
|
|
4
|
+
import { fx } from '../kernel/effects';
|
|
5
|
+
import type { QueryEntry } from '../state/client-state';
|
|
6
|
+
import * as R from '../state/reducers';
|
|
7
|
+
import { overlay, vouched } from '../state/selectors';
|
|
8
|
+
import type { Overlay } from '../state/selectors';
|
|
9
|
+
import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
|
|
10
|
+
import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
|
|
11
|
+
|
|
12
|
+
const NONE: ReadonlySet<string> = new Set();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render one query from state: membership (or a cold scan), the outbox
|
|
16
|
+
* overlay, one local read, then notify subscribers if the rows changed. The
|
|
17
|
+
* only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
|
|
18
|
+
* query is dirty. Before the server has answered (`cold`) only bodies some
|
|
19
|
+
* durable view vouches for paint (see `paintsCold`).
|
|
20
|
+
*/
|
|
21
|
+
export function* materialize(hash: QueryHash): Saga<void> {
|
|
22
|
+
const [entry, ov, known] = (yield fx.state.read((s) => {
|
|
23
|
+
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>];
|
|
26
|
+
if (!entry) return;
|
|
27
|
+
const t0 = (yield fx.now()) as number;
|
|
28
|
+
const isWindow = isWindowed(entry.def.surql);
|
|
29
|
+
const paints = (id: string | null) => id === null || paintsCold(id, known, ov);
|
|
30
|
+
const coldWindow = entry.lifecycle.phase === 'cold' && isWindow;
|
|
31
|
+
const membership = resolveMembership({
|
|
32
|
+
phase: entry.lifecycle.phase,
|
|
33
|
+
remoteArray: entry.remoteArray,
|
|
34
|
+
localArray: coldWindow ? entry.localArray.filter(([id]) => paints(id)) : entry.localArray,
|
|
35
|
+
isWindow,
|
|
36
|
+
});
|
|
37
|
+
const ids = membership
|
|
38
|
+
? buildRenderIds(membership, entry.localArray, ov, {
|
|
39
|
+
hasExplicitOrder: (entry.def.plan?.orderBy?.length ?? 0) > 0,
|
|
40
|
+
isWindow,
|
|
41
|
+
})
|
|
42
|
+
: null;
|
|
43
|
+
const effect = materializeEffect(entry.def, ids);
|
|
44
|
+
let rows: Record<string, unknown>[];
|
|
45
|
+
try {
|
|
46
|
+
rows = rowsFromResult(effect, yield effect);
|
|
47
|
+
if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
yield fx.state.update(R.compose(R.recordError(hash), R.clearDirty(hash)));
|
|
50
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'materialize failed', data: { hash, error } });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
54
|
+
if (!current) return;
|
|
55
|
+
const t1 = (yield fx.now()) as number;
|
|
56
|
+
const changed = !rowsEqual(rows, current.records);
|
|
57
|
+
yield fx.state.update(
|
|
58
|
+
R.compose(R.setRecords(hash, rows, changed, t1 - t0), changed ? R.stampUpdated(hash, t1) : (s) => s)
|
|
59
|
+
);
|
|
60
|
+
if (changed) yield fx.emit({ type: 'query:records', hash, records: rows });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A view update from the in-browser SSP: the query's local id-set moved.
|
|
65
|
+
* State takes the array (which dirties the query); the ingest timings feed
|
|
66
|
+
* telemetry. Synthetic updates no longer exist: re-materialization is dirt.
|
|
67
|
+
*/
|
|
68
|
+
export function* streamUpdate(update: StreamUpdate): Saga<void> {
|
|
69
|
+
const hash = update.queryHash;
|
|
70
|
+
const exists = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
71
|
+
if (!exists) return;
|
|
72
|
+
const phases: Array<[string, number | undefined]> = [
|
|
73
|
+
['sspStoreApply', update.storeApplyMs],
|
|
74
|
+
['sspCircuitStep', update.circuitStepMs],
|
|
75
|
+
['sspTransform', update.transformMs],
|
|
76
|
+
];
|
|
77
|
+
yield fx.state.update(
|
|
78
|
+
R.compose(
|
|
79
|
+
R.setLocalArray(hash, update.localArray),
|
|
80
|
+
typeof update.materializationTimeMs === 'number' ? R.recordIngest(hash, update.materializationTimeMs) : (s) => s,
|
|
81
|
+
...phases.filter((p): p is [string, number] => typeof p[1] === 'number').map(([phase, ms]) => R.recordPhase(hash, phase, ms))
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
|
|
4
|
+
|
|
5
|
+
describe('materializeEffect', () => {
|
|
6
|
+
const plan = { table: 'thing', where: [{ field: 'a', op: '=', value: 1 }] } as any;
|
|
7
|
+
it('plan + ids: id-set plan select without where/limit/offset', () => {
|
|
8
|
+
const e = materializeEffect({ surql: 'SELECT * FROM thing', params: { a: 1 }, plan: { ...plan, limit: 5, offset: 2 } }, ['thing:1']);
|
|
9
|
+
expect(e.kind).toBe('local.select');
|
|
10
|
+
if (e.kind !== 'local.select') throw new Error();
|
|
11
|
+
expect(e.plan.ids).toEqual([new RecordId('thing', '1')]);
|
|
12
|
+
expect(e.plan.where).toBeUndefined();
|
|
13
|
+
expect(e.plan.limit).toBeUndefined();
|
|
14
|
+
expect(e.params).toEqual({ a: 1 });
|
|
15
|
+
});
|
|
16
|
+
it('surql + ids: id-set rewrite bound to __win; unparseable surql falls back to the raw query', () => {
|
|
17
|
+
const e = materializeEffect({ surql: 'SELECT * FROM thing WHERE a = $a ORDER BY b LIMIT 3', params: { a: 1 } }, ['thing:1']);
|
|
18
|
+
expect(e).toMatchObject({ kind: 'local.query', sql: 'SELECT * FROM $__win ORDER BY b' });
|
|
19
|
+
if (e.kind !== 'local.query') throw new Error();
|
|
20
|
+
expect(e.vars).toEqual({ a: 1, __win: [new RecordId('thing', '1')] });
|
|
21
|
+
const raw = materializeEffect({ surql: 'RETURN 1', params: {} }, ['thing:1']);
|
|
22
|
+
expect(raw).toMatchObject({ kind: 'local.query', sql: 'RETURN 1' });
|
|
23
|
+
});
|
|
24
|
+
it('null ids: predicate scan via plan or raw surql', () => {
|
|
25
|
+
expect(materializeEffect({ surql: 's', params: {}, plan }, null)).toMatchObject({ kind: 'local.select', plan });
|
|
26
|
+
expect(materializeEffect({ surql: 'SELECT * FROM thing', params: { x: 1 } }, null)).toMatchObject({
|
|
27
|
+
kind: 'local.query',
|
|
28
|
+
sql: 'SELECT * FROM thing',
|
|
29
|
+
vars: { x: 1 },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('rowsFromResult reads both effect shapes; rowsEqual compares structurally', () => {
|
|
33
|
+
const sel = materializeEffect({ surql: 's', params: {}, plan }, null);
|
|
34
|
+
const q = materializeEffect({ surql: 'SELECT * FROM thing', params: {} }, null);
|
|
35
|
+
expect(rowsFromResult(sel, [{ id: 1 }])).toEqual([{ id: 1 }]);
|
|
36
|
+
expect(rowsFromResult(sel, undefined)).toEqual([]);
|
|
37
|
+
expect(rowsFromResult(q, [[{ id: 1 }]])).toEqual([{ id: 1 }]);
|
|
38
|
+
expect(rowsFromResult(q, [null])).toEqual([]);
|
|
39
|
+
expect(rowsFromResult(q, 'x')).toEqual([]);
|
|
40
|
+
const rows = [{ id: 1 }];
|
|
41
|
+
expect(rowsEqual(rows, rows)).toBe(true);
|
|
42
|
+
expect(rowsEqual(rows, [{ id: 1 }])).toBe(true);
|
|
43
|
+
expect(rowsEqual(rows, [{ id: 2 }])).toBe(false);
|
|
44
|
+
expect(rowsEqual(rows, [])).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it('isWindowed', () => {
|
|
47
|
+
expect(isWindowed('SELECT * FROM t LIMIT 10 START 10')).toBe(true);
|
|
48
|
+
expect(isWindowed('SELECT * FROM t LIMIT 10')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('rowKey', () => {
|
|
53
|
+
it('reads a string id (SQLite) or a RecordId (SurrealDB); null when the row carries none', () => {
|
|
54
|
+
expect(rowKey({ id: 'thing:1' })).toBe('thing:1');
|
|
55
|
+
expect(rowKey({ id: new RecordId('thing', '2') })).toBe('thing:2');
|
|
56
|
+
for (const row of [{ name: 'x' }, { id: null }, { id: { other: 1 } }, null, 5]) expect(rowKey(row)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { RecordId } from 'surrealdb';
|
|
3
|
+
import type { Effect } from '../kernel/effects';
|
|
4
|
+
import { fx } from '../kernel/effects';
|
|
5
|
+
import { encodeRecordId, parseRecordIdString } from '../utils/index';
|
|
6
|
+
import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterialization } from './window-query';
|
|
7
|
+
|
|
8
|
+
export interface MaterializeSource {
|
|
9
|
+
surql: string;
|
|
10
|
+
params: Record<string, unknown>;
|
|
11
|
+
plan?: QueryPlan;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isWindowed = (surql: string): boolean => buildWindowMaterialization(surql) !== null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one read that materializes a query. `ids` is the render set (membership
|
|
18
|
+
* with the overlay applied); `null` means "no membership yet, scan the local
|
|
19
|
+
* store with the query's own predicate".
|
|
20
|
+
*/
|
|
21
|
+
export function materializeEffect(src: MaterializeSource, ids: string[] | null): Effect {
|
|
22
|
+
if (ids !== null) {
|
|
23
|
+
const parsed = ids.map((id) => parseRecordIdString(id));
|
|
24
|
+
if (src.plan) return fx.local.select(buildIdSetPlan(src.plan, parsed), src.params);
|
|
25
|
+
const idSet = buildIdSetSurql(src.surql);
|
|
26
|
+
if (idSet) return fx.local.query(idSet.query, { ...src.params, __win: parsed });
|
|
27
|
+
return fx.local.query(src.surql, src.params);
|
|
28
|
+
}
|
|
29
|
+
if (src.plan) return fx.local.select(src.plan, src.params);
|
|
30
|
+
return fx.local.query(src.surql, src.params);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rows out of either read effect's result. */
|
|
34
|
+
export function rowsFromResult(effect: Effect, result: unknown): Record<string, unknown>[] {
|
|
35
|
+
if (effect.kind === 'local.select') return (result as Record<string, unknown>[] | undefined) ?? [];
|
|
36
|
+
const first = Array.isArray(result) ? result[0] : undefined;
|
|
37
|
+
return Array.isArray(first) ? (first as Record<string, unknown>[]) : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A row's encoded record id: a string on the SQLite engine, a `RecordId` on
|
|
42
|
+
* the SurrealDB one. `null` for a row that carries none (a projection
|
|
43
|
+
* without `id`, a `SELECT VALUE`).
|
|
44
|
+
*/
|
|
45
|
+
export function rowKey(row: unknown): string | null {
|
|
46
|
+
const id = row !== null && typeof row === 'object' ? (row as { id?: unknown }).id : undefined;
|
|
47
|
+
if (typeof id === 'string') return id;
|
|
48
|
+
if (id !== null && typeof id === 'object' && 'table' in id && 'id' in id) return encodeRecordId(id as RecordId<string>);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Cheap structural equality for the "did the rows change" check. */
|
|
53
|
+
export function rowsEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
|
|
54
|
+
if (a === b) return true;
|
|
55
|
+
if (a.length !== b.length) return false;
|
|
56
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
57
|
+
}
|