@spooky-sync/core 0.0.1-canary.25 → 0.0.1-canary.255
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 +78 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2187 -80
- package/dist/index.js +12653 -2581
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +331 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +787 -46
- package/package.json +16 -8
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- 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 +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -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 +56 -0
- package/src/client/router.ts +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +382 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +499 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +37 -2
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -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/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 +2 -1
- package/src/modules/auth/index.ts +138 -25
- 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 +459 -47
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -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/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/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -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 +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/otel/index.ts +13 -10
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +115 -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 +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +285 -0
- package/src/query/register.saga.ts +216 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -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 +193 -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 +145 -62
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- 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 +222 -15
- 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 +1349 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +153 -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.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +59 -3
- 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 +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 -0
- package/src/state/client-state.ts +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -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 +74 -0
- package/src/sync/tabs.saga.ts +93 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +61 -0
- package/src/testing/fake-services.ts +34 -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 +591 -14
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- 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 -864
- package/src/modules/sync/engine.ts +0 -166
- 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
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildRenderIds, resolveMembership } from './render-set';
|
|
3
|
+
|
|
4
|
+
const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
|
|
5
|
+
|
|
6
|
+
describe('resolveMembership', () => {
|
|
7
|
+
it('cold scans, cold window uses the local view, anything else uses membership', () => {
|
|
8
|
+
const remoteArray: [string, number][] = [['t:1', 1]];
|
|
9
|
+
const localArray: [string, number][] = [['t:2', 1]];
|
|
10
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: false })).toBeNull();
|
|
11
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: true })).toBe(localArray);
|
|
12
|
+
expect(resolveMembership({ phase: 'cached', remoteArray, localArray, isWindow: false })).toBe(remoteArray);
|
|
13
|
+
expect(resolveMembership({ phase: 'view-lost', remoteArray, localArray, isWindow: true })).toBe(remoteArray);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('buildRenderIds', () => {
|
|
18
|
+
const opts = { hasExplicitOrder: false, isWindow: false };
|
|
19
|
+
it('membership minus deletes, sorted', () => {
|
|
20
|
+
expect(buildRenderIds([['t:b', 1], ['t:a', 1], ['t:c', 1]], [], ov([], ['t:c']), opts)).toEqual(['t:a', 't:b']);
|
|
21
|
+
});
|
|
22
|
+
it('adds pending writes the local view admits, never twice, never deleted', () => {
|
|
23
|
+
const ids = buildRenderIds(
|
|
24
|
+
[['t:1', 1]],
|
|
25
|
+
[['t:1', 1], ['t:2', 1], ['t:3', 1], ['t:4', 1]],
|
|
26
|
+
ov(['t:1', 't:2', 't:3', 't:9'], ['t:3']),
|
|
27
|
+
opts
|
|
28
|
+
);
|
|
29
|
+
expect(ids).toEqual(['t:1', 't:2']);
|
|
30
|
+
});
|
|
31
|
+
it('keeps server order for windows and explicit orderBy; dedupes membership', () => {
|
|
32
|
+
const m: [string, number][] = [['t:b', 1], ['t:a', 1], ['t:b', 1]];
|
|
33
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: true, isWindow: false })).toEqual(['t:b', 't:a']);
|
|
34
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { RecordVersionArray } from '../types';
|
|
2
|
+
import type { QueryPhase } from '../state/lifecycle';
|
|
3
|
+
import type { Overlay } from '../state/selectors';
|
|
4
|
+
|
|
5
|
+
export interface RenderInput {
|
|
6
|
+
phase: QueryPhase;
|
|
7
|
+
remoteArray: RecordVersionArray;
|
|
8
|
+
localArray: RecordVersionArray;
|
|
9
|
+
isWindow: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The id-set a query renders from, or `null` when it must fall back to a
|
|
14
|
+
* local predicate scan (a cold query that was never resolved on this device).
|
|
15
|
+
* A cold window has no usable scan (re-applying `START m` against the shared
|
|
16
|
+
* store returns the wrong rows), so it renders the SSP's local window instead.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveMembership(input: RenderInput): RecordVersionArray | null {
|
|
19
|
+
if (input.phase !== 'cold') return input.remoteArray;
|
|
20
|
+
if (input.isWindow) return input.localArray;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RenderOptions {
|
|
25
|
+
hasExplicitOrder: boolean;
|
|
26
|
+
isWindow: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* render = (membership ∪ (writes ∩ localView)) − deletes
|
|
31
|
+
*
|
|
32
|
+
* `writes`/`deletes` are the outbox overlay (pending and acked items). The
|
|
33
|
+
* middle term keeps optimistic writes visible without re-admitting stale rows:
|
|
34
|
+
* `localView` (the SSP's local id-set) says whether the written row matches
|
|
35
|
+
* the query per local truth. Sorted unless the query orders itself or is a
|
|
36
|
+
* window (whose id order is the slice order).
|
|
37
|
+
*/
|
|
38
|
+
export function buildRenderIds(
|
|
39
|
+
membership: RecordVersionArray,
|
|
40
|
+
localView: RecordVersionArray,
|
|
41
|
+
overlay: Overlay,
|
|
42
|
+
opts: RenderOptions
|
|
43
|
+
): string[] {
|
|
44
|
+
const ordered: string[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const [id] of membership) {
|
|
47
|
+
if (overlay.deletes.has(id) || seen.has(id)) continue;
|
|
48
|
+
seen.add(id);
|
|
49
|
+
ordered.push(id);
|
|
50
|
+
}
|
|
51
|
+
if (overlay.writes.size > 0) {
|
|
52
|
+
for (const [id] of localView) {
|
|
53
|
+
if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
ordered.push(id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
|
|
59
|
+
return ordered;
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import * as sql from './sql';
|
|
4
|
+
import {
|
|
5
|
+
buildListRefBatchSelect,
|
|
6
|
+
buildListRefSelect,
|
|
7
|
+
buildQueryRowCountBatchSelect,
|
|
8
|
+
buildQueryRowCountSelect,
|
|
9
|
+
buildSubqueryListRefSelect,
|
|
10
|
+
} from '../sync/policy';
|
|
11
|
+
|
|
12
|
+
describe('query sql builders (golden against the previous builders)', () => {
|
|
13
|
+
it('edge and meta selects are byte-identical to the old helpers', () => {
|
|
14
|
+
expect(sql.listRefSelect('_00_list_ref_user_x')).toBe(buildListRefSelect('_00_list_ref_user_x'));
|
|
15
|
+
expect(sql.subqueryListRefSelect('t')).toBe(buildSubqueryListRefSelect('t'));
|
|
16
|
+
expect(sql.queryRowCountSelect()).toBe(buildQueryRowCountSelect());
|
|
17
|
+
expect(sql.listRefBatchSelect('t')).toBe(buildListRefBatchSelect('t'));
|
|
18
|
+
expect(sql.queryRowCountBatchSelect()).toBe(buildQueryRowCountBatchSelect());
|
|
19
|
+
});
|
|
20
|
+
it('composes the single, batch and register requests', () => {
|
|
21
|
+
expect(sql.singleSnapshotSelect('t').split(';\n')).toEqual([
|
|
22
|
+
sql.listRefSelect('t'),
|
|
23
|
+
sql.queryRowCountSelect(),
|
|
24
|
+
sql.subqueryListRefSelect('t'),
|
|
25
|
+
]);
|
|
26
|
+
expect(sql.batchSnapshotSelect('t')).toBe(`${sql.listRefBatchSelect('t')};\n${sql.queryRowCountBatchSelect()}`);
|
|
27
|
+
expect(sql.registerSelect('t').startsWith('fn::query::register($config);\n')).toBe(true);
|
|
28
|
+
const id = new RecordId('_00_query', 'h');
|
|
29
|
+
expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m' })).toEqual({
|
|
30
|
+
config: { id, surql: 's', params: { a: 1 }, ttl: '10m' },
|
|
31
|
+
in: id,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('view rows and ids', () => {
|
|
35
|
+
expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
|
|
36
|
+
expect(sql.viewRow([['t:1', 1]], true, 5)).toEqual({ ids: [['t:1', 1]], confirmed: true, updatedAt: 5 });
|
|
37
|
+
expect(sql.readLegacyViewRows()).toBe('SELECT * FROM _00_window');
|
|
38
|
+
expect(sql.countViewRows()).toContain('_00_view');
|
|
39
|
+
expect(sql.bodySelect()).toBe('SELECT * FROM $ids');
|
|
40
|
+
});
|
|
41
|
+
it('heartbeat batch answers per index and detects reclaimed rows', () => {
|
|
42
|
+
const a = new RecordId('_00_query', 'a');
|
|
43
|
+
const b = new RecordId('_00_query', 'b');
|
|
44
|
+
expect(sql.heartbeatBatch([a, b])).toEqual({
|
|
45
|
+
sql: 'fn::query::heartbeat($id0);\nfn::query::heartbeat($id1)',
|
|
46
|
+
vars: { id0: a, id1: b },
|
|
47
|
+
});
|
|
48
|
+
expect(sql.heartbeatRowGone([])).toBe(true);
|
|
49
|
+
expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
|
|
50
|
+
expect(sql.heartbeatRowGone(undefined)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it('upsertBodiesTx MERGEs each body inside one transaction', () => {
|
|
53
|
+
const { query, vars } = sql.upsertBodiesTx([
|
|
54
|
+
{ id: 'thing:1', content: { a: 1 } },
|
|
55
|
+
{ id: 'thing:2', content: { b: 2 } },
|
|
56
|
+
]);
|
|
57
|
+
expect(query.sql).toBe(
|
|
58
|
+
'BEGIN TRANSACTION;\nUPSERT ONLY $id0 MERGE $content0;UPSERT ONLY $id1 MERGE $content1;\nCOMMIT TRANSACTION;'
|
|
59
|
+
);
|
|
60
|
+
expect(vars).toEqual({ id0: 'thing:1', content0: { a: 1 }, id1: 'thing:2', content1: { b: 2 } });
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/query/sql.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import type { RecordVersionArray } from '../types';
|
|
3
|
+
import type { Vars } from '../kernel/effects';
|
|
4
|
+
import { surql } from '../utils/surql';
|
|
5
|
+
import type { SealedQuery } from '../utils/surql';
|
|
6
|
+
|
|
7
|
+
/** Every SurrealQL statement the query side sends, as pure builders. */
|
|
8
|
+
|
|
9
|
+
export const VIEW_TABLE = '_00_view';
|
|
10
|
+
export const LEGACY_VIEW_TABLE = '_00_window';
|
|
11
|
+
|
|
12
|
+
export const viewRecordId = (viewKey: string): RecordId<string> => new RecordId(VIEW_TABLE, viewKey);
|
|
13
|
+
|
|
14
|
+
export const viewRow = (ids: RecordVersionArray, confirmed: boolean, now: number): Record<string, unknown> => ({
|
|
15
|
+
ids,
|
|
16
|
+
confirmed,
|
|
17
|
+
updatedAt: now,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Copy every legacy `_00_window` row into `_00_view` once. */
|
|
21
|
+
export const readLegacyViewRows = (): string => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
|
|
22
|
+
export const countViewRows = (): string => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
|
|
23
|
+
|
|
24
|
+
export const listRefSelect = (table: string): string =>
|
|
25
|
+
`SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
26
|
+
export const subqueryListRefSelect = (table: string): string =>
|
|
27
|
+
`SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
28
|
+
export const queryRowCountSelect = (): string => 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
|
|
29
|
+
export const listRefBatchSelect = (table: string): string =>
|
|
30
|
+
`SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
|
|
31
|
+
export const queryRowCountBatchSelect = (): string => 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
|
|
32
|
+
|
|
33
|
+
/** The single-query read: edges, meta, subquery children in one request. */
|
|
34
|
+
export const singleSnapshotSelect = (table: string): string =>
|
|
35
|
+
`${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
|
|
36
|
+
/** The many-query read: all edges + all metas in one request. */
|
|
37
|
+
export const batchSnapshotSelect = (table: string): string =>
|
|
38
|
+
`${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
|
|
39
|
+
|
|
40
|
+
export interface RegisterPayload {
|
|
41
|
+
id: RecordId<string>;
|
|
42
|
+
surql: string;
|
|
43
|
+
params: Record<string, unknown>;
|
|
44
|
+
ttl: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Register + read back edges/meta/children in ONE request. */
|
|
48
|
+
export const registerSelect = (table: string): string =>
|
|
49
|
+
`fn::query::register($config);\n${singleSnapshotSelect(table)}`;
|
|
50
|
+
export const registerVars = (payload: RegisterPayload): Vars => ({ config: payload, in: payload.id });
|
|
51
|
+
|
|
52
|
+
/** One heartbeat statement per view id; result index i answers id i. */
|
|
53
|
+
export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: string; vars: Vars } {
|
|
54
|
+
const vars: Vars = {};
|
|
55
|
+
const stmts = ids.map((id, i) => {
|
|
56
|
+
vars[`id${i}`] = id;
|
|
57
|
+
return `fn::query::heartbeat($id${i})`;
|
|
58
|
+
});
|
|
59
|
+
return { sql: stmts.join(';\n'), vars };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A heartbeat answer whose statement matched no row: the view was reclaimed. */
|
|
63
|
+
export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
|
|
64
|
+
|
|
65
|
+
export const bodySelect = (): string => 'SELECT * FROM $ids';
|
|
66
|
+
|
|
67
|
+
/** Local transaction that MERGEs many fetched bodies. */
|
|
68
|
+
export function upsertBodiesTx(records: ReadonlyArray<{ id: unknown; content: Record<string, unknown> }>): {
|
|
69
|
+
query: SealedQuery<unknown>;
|
|
70
|
+
vars: Vars;
|
|
71
|
+
} {
|
|
72
|
+
const vars: Vars = {};
|
|
73
|
+
const stmts = records.map((r, i) => {
|
|
74
|
+
vars[`id${i}`] = r.id;
|
|
75
|
+
vars[`content${i}`] = r.content;
|
|
76
|
+
return surql.upsertMerge(`id${i}`, `content${i}`);
|
|
77
|
+
});
|
|
78
|
+
return { query: surql.seal<void>(surql.tx(stmts)) as SealedQuery<unknown>, vars };
|
|
79
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildWindowMaterialization } from './window-query';
|
|
3
|
+
|
|
4
|
+
describe('scanner edge cases', () => {
|
|
5
|
+
it('skips escaped quotes inside strings and ignores clauses in parens', () => {
|
|
6
|
+
const q = "SELECT * FROM t WHERE a = 'it\\'s (FROM)' AND b IN (SELECT id FROM u LIMIT 1 START 1) LIMIT 5 START 5";
|
|
7
|
+
expect(buildWindowMaterialization(q)).toEqual({ query: 'SELECT * FROM $__win' });
|
|
8
|
+
});
|
|
9
|
+
it('requires whitespace inside ORDER BY and a number after START', () => {
|
|
10
|
+
expect(buildWindowMaterialization('SELECT * FROM t ORDERBY a LIMIT 5 START 5')).toEqual({ query: 'SELECT * FROM $__win' });
|
|
11
|
+
expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START $n')).toBeNull();
|
|
12
|
+
expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('matches a keyword at the very start and at the end of the string', () => {
|
|
15
|
+
expect(buildWindowMaterialization('FROM t LIMIT 5 START 5')).toEqual({ query: ' FROM $__win' });
|
|
16
|
+
expect(buildWindowMaterialization('SELECT * FROM t ORDER BY a LIMIT 5 START 5 ORDER')).toEqual({
|
|
17
|
+
query: 'SELECT * FROM $__win ORDER BY a',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('buildWindowMaterialization', () => {
|
|
23
|
+
it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
|
|
24
|
+
const surql =
|
|
25
|
+
'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
|
|
26
|
+
const r = buildWindowMaterialization(surql);
|
|
27
|
+
expect(r).not.toBeNull();
|
|
28
|
+
expect(r!.query).toBe('SELECT * FROM $__win ORDER BY sort_index asc, date desc');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
|
|
32
|
+
const surql =
|
|
33
|
+
'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 0;';
|
|
34
|
+
expect(buildWindowMaterialization(surql)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns null when there is no START clause', () => {
|
|
38
|
+
expect(
|
|
39
|
+
buildWindowMaterialization('SELECT * FROM game WHERE database = $database LIMIT 30;')
|
|
40
|
+
).toBeNull();
|
|
41
|
+
expect(buildWindowMaterialization('SELECT * FROM game;')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('preserves a custom projection', () => {
|
|
45
|
+
const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
|
|
46
|
+
expect(buildWindowMaterialization(surql)!.query).toBe(
|
|
47
|
+
'SELECT id, white, black FROM $__win ORDER BY date desc'
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('omits ORDER BY when the query had none', () => {
|
|
52
|
+
const surql = 'SELECT * FROM game LIMIT 30 START 30;';
|
|
53
|
+
expect(buildWindowMaterialization(surql)!.query).toBe('SELECT * FROM $__win');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ignores FROM/ORDER BY/LIMIT/START inside subqueries (paren-aware)', () => {
|
|
57
|
+
const surql =
|
|
58
|
+
'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
|
|
59
|
+
'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
|
|
60
|
+
expect(buildWindowMaterialization(surql)!.query).toBe(
|
|
61
|
+
'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM $__win ORDER BY sort_index asc'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not treat a START inside a string literal as the offset', () => {
|
|
66
|
+
const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
|
|
67
|
+
const r = buildWindowMaterialization(surql);
|
|
68
|
+
expect(r!.query).toBe('SELECT * FROM $__win ORDER BY date desc');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
|
|
3
|
+
* materialized from an explicit record-id set — the window the SSP already
|
|
4
|
+
* computed — instead of re-running the original query (with its `START m`)
|
|
5
|
+
* against the shared local DB.
|
|
6
|
+
*
|
|
7
|
+
* Why: `DataModule.processStreamUpdate` materializes a query's rows by
|
|
8
|
+
* re-querying the local in-browser SurrealDB with the original surql. For an
|
|
9
|
+
* offset query that re-applies `START m` against the *shared* local store —
|
|
10
|
+
* which, with sparse windowing, may hold only this window's rows — so it skips
|
|
11
|
+
* them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
|
|
12
|
+
* materialized view (`StreamUpdate.localArray`) is exactly this window's row
|
|
13
|
+
* ids, so we select those ids directly and re-apply the original `ORDER BY` for
|
|
14
|
+
* stable display order.
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` for non-offset queries (`START` absent or 0) — the caller
|
|
17
|
+
* keeps the normal re-query path, so only the broken case changes behavior.
|
|
18
|
+
*
|
|
19
|
+
* Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
|
|
20
|
+
* drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
|
|
21
|
+
* projection are preserved verbatim (the scanner is paren/quote aware), with
|
|
22
|
+
* one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
|
|
23
|
+
* shape — such windowed+subquery queries are rare and were already returning 0,
|
|
24
|
+
* so this is no regression.
|
|
25
|
+
*/
|
|
26
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plan-level window materialization: restrict a windowed query's base rows to
|
|
30
|
+
* exactly the SSP-computed id-set, dropping `where`/`limit`/`offset` but keeping
|
|
31
|
+
* `orderBy`, `select` and `relations`. The engine-neutral counterpart of
|
|
32
|
+
* {@link buildWindowMaterialization} (which does the same by string surgery for
|
|
33
|
+
* the raw-SurrealQL path). Returns `null` for non-offset queries so the caller
|
|
34
|
+
* keeps the normal re-query path.
|
|
35
|
+
*/
|
|
36
|
+
export function buildWindowMaterializationPlan(
|
|
37
|
+
plan: QueryPlan,
|
|
38
|
+
ids: unknown[]
|
|
39
|
+
): QueryPlan | null {
|
|
40
|
+
if (plan.offset === undefined || plan.offset <= 0) return null;
|
|
41
|
+
return buildIdSetPlan(plan, ids);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Raw-SurrealQL counterpart of {@link buildIdSetPlan}, for queries that arrived
|
|
46
|
+
* without a plan. Same rewrite as {@link buildWindowMaterialization} without the
|
|
47
|
+
* offset guard: keep the projection and top-level `ORDER BY`, replace the source
|
|
48
|
+
* with the bound id-set.
|
|
49
|
+
*/
|
|
50
|
+
export function buildIdSetSurql(
|
|
51
|
+
surql: string,
|
|
52
|
+
idsParam = '__win'
|
|
53
|
+
): { query: string } | null {
|
|
54
|
+
const kw = scanTopLevelClauses(surql);
|
|
55
|
+
if (kw.fromIndex === null) return null;
|
|
56
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared body of the two id-set surql rewrites: keep the projection and the
|
|
60
|
+
* top-level `ORDER BY`, replace the source with the bound id-set. */
|
|
61
|
+
function renderIdSetSelect(surql: string, kw: TopLevelClauses, idsParam: string): string {
|
|
62
|
+
const selectClause = surql.slice(0, kw.fromIndex!).trimEnd(); // "SELECT <projection>"
|
|
63
|
+
|
|
64
|
+
let orderBy = '';
|
|
65
|
+
const orderByIndex = kw.orderByIndex;
|
|
66
|
+
if (orderByIndex !== null) {
|
|
67
|
+
const ends = [kw.limitIndex, kw.startIndex, kw.semicolonIndex, surql.length].filter(
|
|
68
|
+
(n): n is number => n !== null && n > orderByIndex
|
|
69
|
+
);
|
|
70
|
+
orderBy = ' ' + surql.slice(orderByIndex, Math.min(...ends)).trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `${selectClause} FROM $${idsParam}${orderBy}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Restrict any plan's base rows to an explicit id-set, regardless of offset.
|
|
78
|
+
*
|
|
79
|
+
* Same rewrite as {@link buildWindowMaterializationPlan} without the offset
|
|
80
|
+
* guard, for rendering a query from its authoritative membership list rather
|
|
81
|
+
* than by re-running its predicate.
|
|
82
|
+
*
|
|
83
|
+
* `where` is dropped deliberately: the id-set already IS the answer to the
|
|
84
|
+
* predicate as the server evaluated it, and re-applying the `WHERE` locally is
|
|
85
|
+
* exactly the bug — a row that left the window keeps its (stale, never
|
|
86
|
+
* re-fetched) local body, so it keeps matching and keeps rendering.
|
|
87
|
+
* `limit`/`offset` go for the same reason: both are already baked into which ids
|
|
88
|
+
* are in the set, so the set itself is the bound. (Both engines ignore them on
|
|
89
|
+
* the `ids` path regardless — only `select` and `orderBy` are honored there.)
|
|
90
|
+
* `orderBy` is kept so display order stays deterministic.
|
|
91
|
+
*/
|
|
92
|
+
export function buildIdSetPlan(plan: QueryPlan, ids: unknown[]): QueryPlan {
|
|
93
|
+
return {
|
|
94
|
+
...plan,
|
|
95
|
+
ids,
|
|
96
|
+
where: undefined,
|
|
97
|
+
limit: undefined,
|
|
98
|
+
offset: undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildWindowMaterialization(
|
|
103
|
+
surql: string,
|
|
104
|
+
idsParam = '__win'
|
|
105
|
+
): { query: string } | null {
|
|
106
|
+
const kw = scanTopLevelClauses(surql);
|
|
107
|
+
// `START` is only recorded once `FROM` was seen, so a positive start value
|
|
108
|
+
// implies a FROM index.
|
|
109
|
+
if (kw.startValue === null || kw.startValue <= 0) return null;
|
|
110
|
+
return { query: renderIdSetSelect(surql, kw, idsParam) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface TopLevelClauses {
|
|
114
|
+
fromIndex: number | null;
|
|
115
|
+
orderByIndex: number | null;
|
|
116
|
+
limitIndex: number | null;
|
|
117
|
+
startIndex: number | null;
|
|
118
|
+
startValue: number | null;
|
|
119
|
+
semicolonIndex: number | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Single pass over the query, tracking paren depth and single-quoted strings so
|
|
123
|
+
// that clauses inside subqueries (which live in parens) are ignored — only the
|
|
124
|
+
// outermost (depth-0) clause keywords are recorded.
|
|
125
|
+
function scanTopLevelClauses(sql: string): TopLevelClauses {
|
|
126
|
+
const out: TopLevelClauses = {
|
|
127
|
+
fromIndex: null,
|
|
128
|
+
orderByIndex: null,
|
|
129
|
+
limitIndex: null,
|
|
130
|
+
startIndex: null,
|
|
131
|
+
startValue: null,
|
|
132
|
+
semicolonIndex: null,
|
|
133
|
+
};
|
|
134
|
+
let depth = 0;
|
|
135
|
+
let inStr = false;
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < sql.length; i++) {
|
|
138
|
+
const ch = sql[i];
|
|
139
|
+
if (inStr) {
|
|
140
|
+
if (ch === '\\') i++; // skip escaped char
|
|
141
|
+
else if (ch === "'") inStr = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === "'") { inStr = true; continue; }
|
|
145
|
+
if (ch === '(') { depth++; continue; }
|
|
146
|
+
if (ch === ')') { depth--; continue; }
|
|
147
|
+
if (depth !== 0) continue;
|
|
148
|
+
if (ch === ';' && out.semicolonIndex === null) { out.semicolonIndex = i; continue; }
|
|
149
|
+
|
|
150
|
+
// Only test keywords at a word boundary.
|
|
151
|
+
if (!isWordBoundary(sql, i)) continue;
|
|
152
|
+
if (out.fromIndex === null && matchKeyword(sql, i, 'FROM')) { out.fromIndex = i; continue; }
|
|
153
|
+
// FROM must come before the trailing clauses; only record them once seen.
|
|
154
|
+
if (out.fromIndex === null) continue;
|
|
155
|
+
if (out.orderByIndex === null && matchKeyword(sql, i, 'ORDER BY')) { out.orderByIndex = i; continue; }
|
|
156
|
+
if (out.limitIndex === null && matchKeyword(sql, i, 'LIMIT')) { out.limitIndex = i; continue; }
|
|
157
|
+
if (out.startIndex === null && matchKeyword(sql, i, 'START')) {
|
|
158
|
+
out.startIndex = i;
|
|
159
|
+
out.startValue = readNumberAfter(sql, i + 'START'.length);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isWordBoundary(sql: string, i: number): boolean {
|
|
167
|
+
if (i === 0) return true;
|
|
168
|
+
return !/[A-Za-z0-9_]/.test(sql[i - 1]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Case-insensitive keyword match where internal whitespace (e.g. "ORDER BY")
|
|
172
|
+
// matches any run of whitespace, and the keyword ends on a non-word char.
|
|
173
|
+
function matchKeyword(sql: string, i: number, keyword: string): boolean {
|
|
174
|
+
const parts = keyword.split(' ');
|
|
175
|
+
let pos = i;
|
|
176
|
+
for (let p = 0; p < parts.length; p++) {
|
|
177
|
+
const word = parts[p];
|
|
178
|
+
if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
|
|
179
|
+
pos += word.length;
|
|
180
|
+
if (p < parts.length - 1) {
|
|
181
|
+
const wsStart = pos;
|
|
182
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
183
|
+
if (pos === wsStart) return false; // required whitespace
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Must end on a non-word char (or end of string).
|
|
187
|
+
return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readNumberAfter(sql: string, from: number): number | null {
|
|
191
|
+
let pos = from;
|
|
192
|
+
while (pos < sql.length && /\s/.test(sql[pos])) pos++;
|
|
193
|
+
const m = /^\d+/.exec(sql.slice(pos));
|
|
194
|
+
return m ? parseInt(m[0], 10) : null;
|
|
195
|
+
}
|