@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261
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 +2355 -81
- package/dist/index.js +13247 -2595
- 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 +793 -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 +187 -0
- package/src/boot/bucket-switch.saga.ts +131 -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 +96 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +386 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +507 -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 +61 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +56 -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 +9 -2
- package/src/modules/auth/impersonation-banner.ts +112 -0
- package/src/modules/auth/impersonation.test.ts +282 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +344 -27
- 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 +529 -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 +317 -0
- package/src/query/membership.saga.ts +262 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +171 -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 +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 +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 +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 +491 -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 +1366 -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 +191 -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 +488 -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 +63 -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 -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
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { RecordId, type Uuid } from 'surrealdb';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { InlineRow, Sp00kyConfig, PersistenceClient } from '../types';
|
|
4
|
+
import type { Logger } from '../services/logger/index';
|
|
5
|
+
import { createLogger } from '../services/logger/index';
|
|
6
|
+
import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
|
|
7
|
+
import type { LocalStore } from '../services/database/index';
|
|
8
|
+
import { StreamProcessorService } from '../services/stream-processor/index';
|
|
9
|
+
import type { StreamUpdate } from '../services/stream-processor/index';
|
|
10
|
+
import { extractSelectPermissions } from '../services/stream-processor/permissions';
|
|
11
|
+
import { EventSystem } from '../events/index';
|
|
12
|
+
import { AuthService } from '../modules/auth/index';
|
|
13
|
+
import { CrdtManager } from '../modules/crdt/index';
|
|
14
|
+
import { preloadLoro } from '../modules/crdt/loro-loader';
|
|
15
|
+
import { LocalStoragePersistenceClient } from '../services/persistence/localstorage';
|
|
16
|
+
import { SurrealDBPersistenceClient } from '../services/persistence/surrealdb';
|
|
17
|
+
import { ResilientPersistenceClient } from '../services/persistence/resilient';
|
|
18
|
+
import { ANON_USER_ID } from '../modules/ref-tables';
|
|
19
|
+
import { detectSharedTabsSupport } from '../services/tabs/support';
|
|
20
|
+
import { TabsCoordinator, type CoordinatorHooks } from '../services/tabs/coordinator';
|
|
21
|
+
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
22
|
+
import { computeTabsFingerprint, hash53 } from '../services/tabs/protocol';
|
|
23
|
+
import type { SqliteCacheEngine } from '../services/database/sqlite-cache-engine';
|
|
24
|
+
import type { BlobCache } from '../services/blobs/index';
|
|
25
|
+
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from '../services/blobs/index';
|
|
26
|
+
import type { Adapters } from '../kernel/interpreter';
|
|
27
|
+
import type { RuntimeEvent } from '../kernel/events';
|
|
28
|
+
import type { ServiceCalls } from '../kernel/effects';
|
|
29
|
+
import { encodeRecordId } from '../utils/index';
|
|
30
|
+
import { mintMutationId } from '../mutation/mutation-id';
|
|
31
|
+
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
32
|
+
|
|
33
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
34
|
+
|
|
35
|
+
export function readBootBucketHint(): string | null {
|
|
36
|
+
try {
|
|
37
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeBootBucketHint(bucketId: string): void {
|
|
44
|
+
try {
|
|
45
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
46
|
+
} catch {
|
|
47
|
+
/* private-mode storage errors: boot falls back to the anon bucket */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sha256Hex(input: string): Promise<string> {
|
|
52
|
+
const bytes = new TextEncoder().encode(input);
|
|
53
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
54
|
+
return Array.from(new Uint8Array(digest))
|
|
55
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mintSalt(): string {
|
|
60
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
61
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
62
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Everything the facade talks to besides the runtime. */
|
|
66
|
+
export interface Services<S extends SchemaStructure> {
|
|
67
|
+
logger: Logger;
|
|
68
|
+
local: LocalStore;
|
|
69
|
+
remote: RemoteDatabaseService;
|
|
70
|
+
connectionSupervisor: ConnectionSupervisor;
|
|
71
|
+
blobs: BlobCache;
|
|
72
|
+
persistence: PersistenceClient;
|
|
73
|
+
streamProcessor: StreamProcessorService;
|
|
74
|
+
migrator: LocalMigrator;
|
|
75
|
+
crdt: CrdtManager;
|
|
76
|
+
auth: AuthService<S>;
|
|
77
|
+
tabs: TabsCoordinator | null;
|
|
78
|
+
tabsUnsupportedReason: string | undefined;
|
|
79
|
+
tabId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the services need back from the runtime once it exists. */
|
|
83
|
+
export interface ServiceHost {
|
|
84
|
+
dispatch(event: RuntimeEvent): void;
|
|
85
|
+
/** Called by the tabs coordinator hooks with the hub / forwarder to relay through. */
|
|
86
|
+
setTabsChannel(channel: { hub: LeaderSyncHub | null; forwarder: SyncForwarder | null }): void;
|
|
87
|
+
/** Late-bound modules the boot saga starts (feature flags, app releases). */
|
|
88
|
+
initModules(): void;
|
|
89
|
+
detachWindow(): void;
|
|
90
|
+
setDetachWindow(fn: () => void): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S>): Services<S> {
|
|
94
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit);
|
|
95
|
+
if (config.crdt) void preloadLoro();
|
|
96
|
+
const tabsSupport = detectSharedTabsSupport(config);
|
|
97
|
+
const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
|
|
98
|
+
const remote = new RemoteDatabaseService(config.database, logger);
|
|
99
|
+
const connectionSupervisor = new ConnectionSupervisor(remote, logger);
|
|
100
|
+
const rawBucket = (name: string) => new BucketHandle(name, remote, null, { blurhash: config.blurhash, logger });
|
|
101
|
+
const blobs = createBlobCache({
|
|
102
|
+
local,
|
|
103
|
+
namespace: ANON_USER_ID,
|
|
104
|
+
logger,
|
|
105
|
+
fetchRemote: async (key) => bucketContentToBlob(await rawBucket(key.bucket).get(key.path)),
|
|
106
|
+
headRemote: (key) => rawBucket(key.bucket).head(key.path),
|
|
107
|
+
maxBytes: config.blobCache?.maxBytes,
|
|
108
|
+
store: config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : undefined,
|
|
109
|
+
});
|
|
110
|
+
let persistence: PersistenceClient;
|
|
111
|
+
if (config.persistenceClient === 'surrealdb') persistence = new SurrealDBPersistenceClient(local, logger);
|
|
112
|
+
else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) persistence = new LocalStoragePersistenceClient(logger);
|
|
113
|
+
else persistence = config.persistenceClient;
|
|
114
|
+
persistence = new ResilientPersistenceClient(persistence, logger);
|
|
115
|
+
const streamProcessor = new StreamProcessorService(new EventSystem(['stream_update']), local, logger);
|
|
116
|
+
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
|
|
117
|
+
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
118
|
+
const migrator = new LocalMigrator(local, logger);
|
|
119
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
|
|
120
|
+
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
121
|
+
const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
122
|
+
return {
|
|
123
|
+
logger,
|
|
124
|
+
local,
|
|
125
|
+
remote,
|
|
126
|
+
connectionSupervisor,
|
|
127
|
+
blobs,
|
|
128
|
+
persistence,
|
|
129
|
+
streamProcessor,
|
|
130
|
+
migrator,
|
|
131
|
+
crdt,
|
|
132
|
+
auth,
|
|
133
|
+
tabs: null,
|
|
134
|
+
tabsUnsupportedReason: tabsSupport.supported ? undefined : (tabsSupport as { reason: string }).reason,
|
|
135
|
+
tabId,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The shared-tabs coordinator, wired so role changes and relays become runtime events. */
|
|
140
|
+
export function createTabsCoordinator<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost): TabsCoordinator {
|
|
141
|
+
const engine = s.local as SqliteCacheEngine;
|
|
142
|
+
const hooks: CoordinatorHooks = {
|
|
143
|
+
adoptOwner: (bucketId, opts) =>
|
|
144
|
+
engine.adoptOwner(bucketId, { workerLockName: opts.workerLockName, allowMemoryFallback: opts.allowMemoryFallback, resumeHeld: opts.resumeHeld }),
|
|
145
|
+
adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
|
|
146
|
+
releaseOwnership: () => engine.releaseOwnership(),
|
|
147
|
+
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
148
|
+
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
149
|
+
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
150
|
+
becomeSyncLeader: (hub) => {
|
|
151
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
152
|
+
hub.onFollowerMessage = (_tabId, msg) => host.dispatch({ type: 'TabMessage', message: followerToRuntime(msg) });
|
|
153
|
+
host.setTabsChannel({ hub, forwarder: null });
|
|
154
|
+
host.dispatch({ type: 'TabRole', role: 'leader' });
|
|
155
|
+
},
|
|
156
|
+
resumeSyncLeaderDuties: async () => {
|
|
157
|
+
host.dispatch({ type: 'Drain' });
|
|
158
|
+
host.dispatch({ type: 'LiveStart' });
|
|
159
|
+
},
|
|
160
|
+
becomeSyncFollower: (forwarder) => {
|
|
161
|
+
s.streamProcessor.setPersistenceEnabled(false);
|
|
162
|
+
forwarder.onLeaderMessage = (msg) => host.dispatch({ type: 'TabMessage', message: leaderToRuntime(msg) });
|
|
163
|
+
host.setTabsChannel({ hub: null, forwarder });
|
|
164
|
+
host.dispatch({ type: 'TabRole', role: 'follower' });
|
|
165
|
+
},
|
|
166
|
+
becomeSyncSolo: () => {
|
|
167
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
168
|
+
host.setTabsChannel({ hub: null, forwarder: null });
|
|
169
|
+
host.dispatch({ type: 'TabRole', role: 'solo' });
|
|
170
|
+
},
|
|
171
|
+
currentStorageHealth: () => s.local.storageHealth ?? { status: 'unknown', fallback: false },
|
|
172
|
+
};
|
|
173
|
+
return new TabsCoordinator({
|
|
174
|
+
tabId: s.tabId,
|
|
175
|
+
fingerprint: computeTabsFingerprint({
|
|
176
|
+
coreVersion: typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown',
|
|
177
|
+
schemaHash: hash53(config.schemaSurql),
|
|
178
|
+
endpoint: config.database.endpoint ?? '',
|
|
179
|
+
namespace: config.database.namespace,
|
|
180
|
+
database: config.database.database,
|
|
181
|
+
}),
|
|
182
|
+
hooks,
|
|
183
|
+
logger: s.logger,
|
|
184
|
+
onLeaderPageHide: () => void engine.shutdownOwnedWorker(),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Wire-protocol messages become the runtime's tab messages. */
|
|
189
|
+
export function followerToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
190
|
+
switch (msg.type) {
|
|
191
|
+
case 'mutation-enqueued':
|
|
192
|
+
return { type: 'outbox-changed', mutationId: msg.mutationId };
|
|
193
|
+
case 'ingest':
|
|
194
|
+
return { type: 'ingest', records: msg.tuples };
|
|
195
|
+
case 'request-poll':
|
|
196
|
+
return { type: 'membership-dirty', hashes: [] };
|
|
197
|
+
default:
|
|
198
|
+
return msg;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function leaderToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
203
|
+
switch (msg.type) {
|
|
204
|
+
case 'ingest-relay':
|
|
205
|
+
return { type: 'ingest', records: msg.tuples };
|
|
206
|
+
default:
|
|
207
|
+
return msg;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
|
|
212
|
+
export function hashOfEdge(value: unknown): string | null {
|
|
213
|
+
const inId = (value as { in?: unknown } | null)?.in;
|
|
214
|
+
if (!inId) return null;
|
|
215
|
+
const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, '');
|
|
216
|
+
return str.length > 0 ? str : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The row an edge notification carries, when the subscription joined it on.
|
|
221
|
+
*
|
|
222
|
+
* Without `FETCH out` the edge's `out` is a record id and there is nothing to
|
|
223
|
+
* land, so this returns null and the caller falls back to fetching the body.
|
|
224
|
+
* It also returns null for a row the session may not read (the join yields
|
|
225
|
+
* `out: null`) and for an edge whose target has been deleted.
|
|
226
|
+
*/
|
|
227
|
+
export function rowOfEdge(value: unknown): InlineRow | null {
|
|
228
|
+
const edge = value as { out?: unknown; version?: unknown } | null;
|
|
229
|
+
const out = edge?.out;
|
|
230
|
+
if (!out || typeof out !== 'object' || Array.isArray(out) || out instanceof RecordId) return null;
|
|
231
|
+
// Must be a real RecordId: the landing path keys off `row.id` being one, and
|
|
232
|
+
// would otherwise record the version for a body it never wrote.
|
|
233
|
+
const rid = (out as { id?: unknown }).id;
|
|
234
|
+
if (!(rid instanceof RecordId)) return null;
|
|
235
|
+
const id = encodeRecordId(rid);
|
|
236
|
+
const version = typeof edge?.version === 'number' ? edge.version : null;
|
|
237
|
+
if (version === null) return null;
|
|
238
|
+
return { id, version, record: out as Record<string, unknown> };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Build the adapters the interpreter drives from the services. */
|
|
242
|
+
export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
|
|
243
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
244
|
+
const services: ServiceCalls = {
|
|
245
|
+
'hint.read': readBootBucketHint,
|
|
246
|
+
'hint.write': writeBootBucketHint,
|
|
247
|
+
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
248
|
+
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
249
|
+
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
250
|
+
'local.currentBucketId': () => s.local.currentBucketId,
|
|
251
|
+
'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
|
|
252
|
+
'local.dropBucket': async (bucketId) => {
|
|
253
|
+
await s.local.dropBucket?.(bucketId);
|
|
254
|
+
},
|
|
255
|
+
'migrator.provision': () => s.migrator.provision(config.schemaSurql),
|
|
256
|
+
'blobs.start': async (bucketId) => {
|
|
257
|
+
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
258
|
+
await s.blobs.start(bucketId);
|
|
259
|
+
},
|
|
260
|
+
'blobs.setNamespace': (bucketId) => s.blobs.setNamespace(bucketId),
|
|
261
|
+
'blobs.clear': () => s.blobs.clear(),
|
|
262
|
+
'ssp.init': () => s.streamProcessor.init(),
|
|
263
|
+
'ssp.setPermissions': () => s.streamProcessor.setPermissions(extractSelectPermissions(config.schemaSurql)),
|
|
264
|
+
'ssp.setSessionAuth': (authId, access) => s.streamProcessor.setSessionAuth(authId, access),
|
|
265
|
+
'ssp.prime': (pendingIds) =>
|
|
266
|
+
s.streamProcessor.primeFromLocal({
|
|
267
|
+
tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
|
|
268
|
+
schemaHash: String(hash53(config.schemaSurql)),
|
|
269
|
+
pendingIds: new Set(pendingIds),
|
|
270
|
+
onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
|
|
271
|
+
}),
|
|
272
|
+
'ssp.reset': () => s.streamProcessor.reset(),
|
|
273
|
+
'ssp.setPersistence': (enabled) => s.streamProcessor.setPersistenceEnabled(enabled),
|
|
274
|
+
'auth.restoreSession': () => s.auth.restoreSessionFromToken(),
|
|
275
|
+
'auth.init': () => s.auth.init(),
|
|
276
|
+
'auth.sessionAuthId': () => {
|
|
277
|
+
const id = s.auth.currentUser?.id;
|
|
278
|
+
if (!id) return null;
|
|
279
|
+
return typeof id === 'string' ? id : encodeRecordId(id);
|
|
280
|
+
},
|
|
281
|
+
'auth.access': () => s.auth.access,
|
|
282
|
+
'auth.token': () => s.auth.token,
|
|
283
|
+
'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
|
|
284
|
+
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
285
|
+
'remote.connect': () => s.remote.connect(),
|
|
286
|
+
'remote.releaseViews': (ids) => {
|
|
287
|
+
const list = ids
|
|
288
|
+
.map((id) => String(id))
|
|
289
|
+
.filter((id) => /^_00_query:[0-9a-f]{64}$/.test(id))
|
|
290
|
+
.join(', ');
|
|
291
|
+
if (list) s.remote.beaconSql(`FOR $id IN [${list}] { LET $_released = fn::query::unsubscribe($id); };`);
|
|
292
|
+
},
|
|
293
|
+
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
294
|
+
'tabs.start': (bucketId) => {
|
|
295
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
296
|
+
return s.tabs.start(bucketId);
|
|
297
|
+
},
|
|
298
|
+
'tabs.moveToBucket': (bucketId) => {
|
|
299
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
300
|
+
return s.tabs.moveToBucket(bucketId);
|
|
301
|
+
},
|
|
302
|
+
'crdt.setSessionId': (id) => s.crdt.setSessionId(id),
|
|
303
|
+
'crdt.closeAll': (flush) => s.crdt.closeAll({ flush }),
|
|
304
|
+
'persistence.set': (key, value) => s.persistence.set(key, value),
|
|
305
|
+
'features.init': () => {
|
|
306
|
+
for (const m of lateModules()) m.init();
|
|
307
|
+
},
|
|
308
|
+
'releases.init': () => undefined,
|
|
309
|
+
'window.attach': () => {
|
|
310
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
311
|
+
const onPageHide = (event: PageTransitionEvent) => {
|
|
312
|
+
if (event.persisted) return;
|
|
313
|
+
host.dispatch({ type: 'PageHide' });
|
|
314
|
+
};
|
|
315
|
+
const onWake = () => {
|
|
316
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
317
|
+
host.dispatch({ type: 'HeartbeatNow' });
|
|
318
|
+
};
|
|
319
|
+
window.addEventListener('pagehide', onPageHide);
|
|
320
|
+
window.addEventListener('online', onWake);
|
|
321
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onWake);
|
|
322
|
+
host.setDetachWindow(() => {
|
|
323
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
324
|
+
window.removeEventListener('online', onWake);
|
|
325
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onWake);
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
local: s.local,
|
|
331
|
+
remote: {
|
|
332
|
+
queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
|
|
333
|
+
live: async (table, onChange) => {
|
|
334
|
+
// `FETCH out` resolves the edge's target into the notification, so the
|
|
335
|
+
// body arrives with the doorbell instead of costing a second round
|
|
336
|
+
// trip. The clause is only ever added, never relied on: a server that
|
|
337
|
+
// ignores it, or a row the session cannot read, simply yields no
|
|
338
|
+
// inline row and the fetch path takes over.
|
|
339
|
+
const join = config.liveInlineBodies ? ' FETCH out' : '';
|
|
340
|
+
const [uuid] = await s.remote.query<[Uuid]>(`LIVE SELECT * FROM ${table}${join}`);
|
|
341
|
+
const live = await s.remote.getClient().liveOf(uuid);
|
|
342
|
+
live.subscribe((message) => {
|
|
343
|
+
if (message.action === 'KILLED') return;
|
|
344
|
+
const hash = hashOfEdge(message.value);
|
|
345
|
+
if (!hash) return;
|
|
346
|
+
// Not on DELETE: that edge is a row leaving the view, and for a row
|
|
347
|
+
// that was actually deleted the join yields nothing anyway. Landing
|
|
348
|
+
// a body there would only ever write back what is going away.
|
|
349
|
+
const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
|
|
350
|
+
onChange([hash], row ? [row] : undefined);
|
|
351
|
+
});
|
|
352
|
+
return String(uuid);
|
|
353
|
+
},
|
|
354
|
+
kill: async (uuid) => {
|
|
355
|
+
await s.remote.query('KILL $u', { u: uuid });
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
ssp: {
|
|
359
|
+
registerQueryPlan: (plan) => s.streamProcessor.registerQueryPlan(plan) as StreamUpdate | undefined,
|
|
360
|
+
unregisterQueryPlan: (hash) => s.streamProcessor.unregisterQueryPlan(hash),
|
|
361
|
+
ingestMany: (records) => s.streamProcessor.ingestMany(records),
|
|
362
|
+
},
|
|
363
|
+
timers: {
|
|
364
|
+
set: (key, ms, fire) => {
|
|
365
|
+
const prev = timers.get(key);
|
|
366
|
+
if (prev) clearTimeout(prev);
|
|
367
|
+
timers.set(
|
|
368
|
+
key,
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
timers.delete(key);
|
|
371
|
+
fire();
|
|
372
|
+
}, ms)
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
clear: (key) => {
|
|
376
|
+
const prev = timers.get(key);
|
|
377
|
+
if (prev) clearTimeout(prev);
|
|
378
|
+
timers.delete(key);
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
clock: { now: () => Date.now() },
|
|
382
|
+
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
|
|
383
|
+
hash: sha256Hex,
|
|
384
|
+
services,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { Sp00kyClient } from './sp00ky-client';
|
|
4
|
+
import { Runtime } from './runtime';
|
|
5
|
+
import { fakeAdapters } from '../testing/adapters';
|
|
6
|
+
import { fakeServiceBundle } from '../testing/fake-services';
|
|
7
|
+
import { defaultEnv } from '../query/env';
|
|
8
|
+
import type { StatementResult } from '../kernel/effects';
|
|
9
|
+
|
|
10
|
+
const schema = { tables: [{ name: 'thing', columns: { a: {} } }], backends: { api: { outboxTable: 'thing', routes: { go: { args: {} } } } } } as any;
|
|
11
|
+
const config = { database: { namespace: 'n', database: 'd' }, schema, schemaSurql: '', logLevel: 'silent' } as any;
|
|
12
|
+
const ok = (result: unknown): StatementResult => ({ status: 'OK', result });
|
|
13
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
14
|
+
const flush = async (n = 6) => {
|
|
15
|
+
for (let i = 0; i < n; i++) await tick();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function makeClient(over: Parameters<typeof fakeAdapters>[0] = {}) {
|
|
19
|
+
const services = fakeServiceBundle<any>();
|
|
20
|
+
const a = fakeAdapters({
|
|
21
|
+
services: {
|
|
22
|
+
'hint.read': () => 'u1',
|
|
23
|
+
'local.usesSurqlSchema': () => false,
|
|
24
|
+
'auth.restoreSession': async () => null,
|
|
25
|
+
'auth.sessionAuthId': () => null,
|
|
26
|
+
'remote.connect': () => new Promise(() => {}),
|
|
27
|
+
...over.services,
|
|
28
|
+
},
|
|
29
|
+
...over,
|
|
30
|
+
});
|
|
31
|
+
const runtime = new Runtime({ env: defaultEnv(schema, { materializeDebounceMs: 1 }), adapters: a.adapters, logger: services.logger, tabId: 'tab-test' });
|
|
32
|
+
const client = new Sp00kyClient<any>(config, { services, runtime });
|
|
33
|
+
return { client, a, services, runtime };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Sp00kyClient facade', () => {
|
|
37
|
+
it('init resolves from the local store while the remote connect never does', async () => {
|
|
38
|
+
const { client, a } = makeClient();
|
|
39
|
+
expect(client.isLocalReady()).toBe(false);
|
|
40
|
+
await client.init();
|
|
41
|
+
expect(client.isLocalReady()).toBe(true);
|
|
42
|
+
await flush();
|
|
43
|
+
expect(a.names()).toContain('service.remote.connect');
|
|
44
|
+
expect(a.names().indexOf('service.local.connect')).toBeLessThan(a.names().indexOf('service.remote.connect'));
|
|
45
|
+
expect(client.syncHealth.status).toBe('healthy');
|
|
46
|
+
expect(client.storageHealth).toEqual({ status: 'unknown', fallback: false });
|
|
47
|
+
expect(client.tabRole).toBeNull();
|
|
48
|
+
expect(client.liveRetryCount).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
|
|
52
|
+
const { client, a } = makeClient({ local: { query: (async (sql: string) => (sql.startsWith('SELECT * FROM thing') ? [[{ id: 'thing:1' }]] : [])) as any } });
|
|
53
|
+
await client.init();
|
|
54
|
+
const hash = await client.queryRaw('SELECT * FROM thing', {}, '10m');
|
|
55
|
+
expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('cold');
|
|
56
|
+
const rows: unknown[] = [];
|
|
57
|
+
const off = await client.subscribe(hash, (r) => rows.push(r), { immediate: true });
|
|
58
|
+
a.timers.fire(`mat:${hash}`);
|
|
59
|
+
await flush();
|
|
60
|
+
expect(rows.at(-1)).toEqual([{ id: 'thing:1' }]);
|
|
61
|
+
const statuses: string[] = [];
|
|
62
|
+
client.subscribeQueryStatus(hash, (s) => statuses.push(s), { immediate: true });
|
|
63
|
+
const auth: boolean[] = [];
|
|
64
|
+
client.subscribeQueryAuthority(hash, (k) => auth.push(k), { immediate: true });
|
|
65
|
+
expect(statuses[0]).toBeDefined();
|
|
66
|
+
expect(auth).toEqual([false]);
|
|
67
|
+
client.reportFrontendTiming(hash, 3);
|
|
68
|
+
client.reportFrontendTiming(hash, Number.NaN);
|
|
69
|
+
expect(client.state.queries.get(hash)!.telemetry.phaseLast.frontend).toBe(3);
|
|
70
|
+
off();
|
|
71
|
+
client.deregisterQuery(hash);
|
|
72
|
+
await flush();
|
|
73
|
+
expect(client.state.queries.has(hash)).toBe(false);
|
|
74
|
+
client.deregisterQuery('missing');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('create writes locally, mirrors the outbox and reports activity; the tray API reads state', async () => {
|
|
78
|
+
const { client } = makeClient({
|
|
79
|
+
local: {
|
|
80
|
+
execute: (async () => ({ id: new RecordId('thing', '1'), a: 1 })) as any,
|
|
81
|
+
// The drain reads the pending rows back; hand it delete rows so the (empty) push answer keeps them queued.
|
|
82
|
+
query: (async (sql: string, vars: any) =>
|
|
83
|
+
sql === 'SELECT * FROM $ids' && String(vars.ids[0]).startsWith('_00_pending_mutations')
|
|
84
|
+
? [vars.ids.map((id: unknown) => ({ id: String(id), mutationType: 'delete', recordId: 'thing:1' }))]
|
|
85
|
+
: []) as any,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await client.init();
|
|
89
|
+
const counts: number[] = [];
|
|
90
|
+
const off = client.subscribeToPendingMutations((n) => counts.push(n));
|
|
91
|
+
const created = await client.create('thing:1', { a: 1 });
|
|
92
|
+
expect(created).toEqual({ id: new RecordId('thing', '1'), a: 1 });
|
|
93
|
+
expect(client.pendingMutationCount).toBe(1);
|
|
94
|
+
expect(counts).toEqual([0, 1]);
|
|
95
|
+
off();
|
|
96
|
+
const updated = await client.update('thing', 'thing:1', { a: 2 });
|
|
97
|
+
expect(updated).toEqual({ a: 2 });
|
|
98
|
+
await client.delete('thing', 'thing:1');
|
|
99
|
+
await client.run('api', 'go', {} as never);
|
|
100
|
+
expect(client.state.outbox).toHaveLength(4);
|
|
101
|
+
const fetching: number[] = [];
|
|
102
|
+
client.subscribeToFetchActivity((n) => fetching.push(n));
|
|
103
|
+
expect(fetching).toEqual([0]);
|
|
104
|
+
const tray: number[] = [];
|
|
105
|
+
client.subscribeToFailedMutations((n) => tray.push(n));
|
|
106
|
+
expect(tray).toEqual([0]);
|
|
107
|
+
expect(client.failedMutationCount).toBe(0);
|
|
108
|
+
expect(await client.listFailedMutations()).toEqual([]);
|
|
109
|
+
expect(await client.retryFailedMutation('nope')).toBe(false);
|
|
110
|
+
expect(await client.discardFailedMutation('nope')).toBe(false);
|
|
111
|
+
const health: unknown[] = [];
|
|
112
|
+
client.subscribeToSyncHealth((h) => health.push(h.status));
|
|
113
|
+
expect(health).toEqual([client.syncHealth.status]);
|
|
114
|
+
let stored = 0;
|
|
115
|
+
client.subscribeToStorageHealth(() => stored++);
|
|
116
|
+
expect(stored).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('preload: cold waits for membership + bodies; cached returns at once; abort rejects', async () => {
|
|
120
|
+
const edges = [{ out: new RecordId('thing', '1'), version: 1 }];
|
|
121
|
+
const { client, a } = makeClient({
|
|
122
|
+
remote: {
|
|
123
|
+
queryResponses: async (sql: string) => {
|
|
124
|
+
if (sql.startsWith('fn::query::register')) return [ok(null), ok(edges), ok({ rowCount: 1, state: 'ready' }), ok([])];
|
|
125
|
+
if (sql.startsWith('SELECT * FROM $ids')) return [ok([{ id: new RecordId('thing', '1'), a: 1 }])];
|
|
126
|
+
return [ok(null)];
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
local: { select: (async () => [{ id: 'thing:1' }]) as any },
|
|
130
|
+
});
|
|
131
|
+
await client.init();
|
|
132
|
+
a.timers.fire('lifecycle');
|
|
133
|
+
const q = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing', vars: {}, plan: { table: 'thing' } } } } as any;
|
|
134
|
+
const done = client.preload(q).then(() => 'settled');
|
|
135
|
+
await flush(10);
|
|
136
|
+
for (const key of [...a.timers.pending.keys()]) if (key.startsWith('mat:')) a.timers.fire(key);
|
|
137
|
+
await flush(10);
|
|
138
|
+
expect(await done).toBe('settled');
|
|
139
|
+
const hash = [...client.state.queries.keys()][0];
|
|
140
|
+
expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('live');
|
|
141
|
+
await expect(client.preload(q)).resolves.toBeUndefined();
|
|
142
|
+
const ctrl = new AbortController();
|
|
143
|
+
const q2 = { innerQuery: { tableName: 'thing', selectQuery: { query: 'SELECT * FROM thing WHERE a = 1', vars: {} } } } as any;
|
|
144
|
+
const aborted = client.preload(q2, { signal: ctrl.signal });
|
|
145
|
+
await flush(4);
|
|
146
|
+
ctrl.abort();
|
|
147
|
+
await expect(aborted).rejects.toThrow('aborted');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('adapter callbacks become runtime events; tabs relays reach the hub/forwarder; close tears down', async () => {
|
|
151
|
+
const { client, services, runtime } = makeClient();
|
|
152
|
+
await client.init();
|
|
153
|
+
const seen: string[] = [];
|
|
154
|
+
runtime.on('*', (e) => seen.push(e.type));
|
|
155
|
+
services.receivers.forEach((r: any) => r.onStreamUpdate?.({ queryHash: 'zz', localArray: [] }));
|
|
156
|
+
services.connectionListeners.forEach((cb) => cb('connected'));
|
|
157
|
+
services.authListeners.forEach((cb) => cb(null));
|
|
158
|
+
await flush();
|
|
159
|
+
const sent: unknown[] = [];
|
|
160
|
+
(client as any).hub = { relayIngest: (t: unknown) => sent.push(['relay', t]), broadcast: (m: unknown) => sent.push(['bcast', m]), sendTo: (id: string, m: unknown) => sent.push(['to', id, m]) };
|
|
161
|
+
runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
|
|
162
|
+
runtime.emit({ type: 'tabs:broadcast', message: { type: 'mutation-settled' } });
|
|
163
|
+
runtime.emit({ type: 'tabs:sendTo', tabId: 'x', message: { type: 'mutation-rolled-back' } });
|
|
164
|
+
(client as any).hub = null;
|
|
165
|
+
(client as any).forwarder = { ingest: (t: unknown) => sent.push(['f-ingest', t]), mutationEnqueued: (id: string) => sent.push(['f-enq', id]) };
|
|
166
|
+
runtime.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: [] } });
|
|
167
|
+
runtime.emit({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: 'm' } });
|
|
168
|
+
runtime.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: [] } });
|
|
169
|
+
expect(sent).toEqual([['relay', []], ['bcast', { type: 'mutation-settled' }], ['to', 'x', { type: 'mutation-rolled-back' }], ['f-ingest', []], ['f-enq', 'm']]);
|
|
170
|
+
runtime.emit({ type: 'query:status', hash: 'h', status: 'idle' });
|
|
171
|
+
runtime.emit({ type: 'query:authority', hash: 'h', known: true });
|
|
172
|
+
runtime.emit({ type: 'query:view-lost', hash: 'h' });
|
|
173
|
+
runtime.emit({ type: 'mutation:event', event: { type: 'create', record_id: new RecordId('thing', '1'), data: {} } });
|
|
174
|
+
runtime.emit({ type: 'devtools', name: 'X', data: 1 });
|
|
175
|
+
expect(await client.authenticate('tok')).toBe('tok');
|
|
176
|
+
await client.deauthenticate();
|
|
177
|
+
expect(await client.useRemote(async () => 'r')).toBe('r');
|
|
178
|
+
expect(await client.remoteQuery('RETURN 1')).toEqual(['remote']);
|
|
179
|
+
expect(client.remoteClient).toBeDefined();
|
|
180
|
+
expect(client.localClient).toBe('local-client');
|
|
181
|
+
expect(client.getBlobCacheStats()).toEqual({ entries: 0 });
|
|
182
|
+
expect(client.bucket('files')).toBeDefined();
|
|
183
|
+
expect(await client.openCrdtField('t', 'r', 'f')).toBe('field');
|
|
184
|
+
client.closeCrdtField('t', 'r', 'f');
|
|
185
|
+
expect(client.getFeatureOverrides()).toEqual({});
|
|
186
|
+
client.setFeatureOverride('k', 'v');
|
|
187
|
+
client.clearFeatureOverrides();
|
|
188
|
+
expect(client.feature('k')).toBeDefined();
|
|
189
|
+
expect(client.appRelease('web')).toBeDefined();
|
|
190
|
+
await client.close();
|
|
191
|
+
expect(client.state.localReady).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('Sp00kyClient facade (builder, devtools source, event fan-out)', () => {
|
|
196
|
+
it('query() builds through the QueryBuilder and registers locally; dispatch feeds the engine; DevTools reads state', async () => {
|
|
197
|
+
const { client, runtime } = makeClient();
|
|
198
|
+
await client.init();
|
|
199
|
+
const { hash } = await (client.query('thing', {} as any) as any).build().run();
|
|
200
|
+
expect(client.state.queries.has(hash)).toBe(true);
|
|
201
|
+
const source = (client as any).devTools.dataManager;
|
|
202
|
+
expect(source.getActiveQueries().map((q: any) => q.config.surql)).toEqual(['SELECT * FROM thing;']);
|
|
203
|
+
const entry = client.state.queries.get(hash)!;
|
|
204
|
+
expect(source.getQueryById(entry.def.id)!.config.membershipKey).toBe(entry.def.viewKey);
|
|
205
|
+
expect(source.getQueryById({ id: 'nope', table: '_00_query' } as any)).toBeUndefined();
|
|
206
|
+
expect(source.phaseTimings(source.getActiveQueries()[0]).updateCount).toBe(0);
|
|
207
|
+
expect(source.phaseTimings({ config: { id: { id: 'nope' } } } as any)).toEqual({});
|
|
208
|
+
await client.dispatch({ type: 'PollTick' });
|
|
209
|
+
await expect((client.query('nope' as any, {} as any) as any).build().run()).rejects.toThrow('Table nope not found');
|
|
210
|
+
const tray: number[] = [];
|
|
211
|
+
const fetching: number[] = [];
|
|
212
|
+
const health: string[] = [];
|
|
213
|
+
client.subscribeToFailedMutations((n) => tray.push(n));
|
|
214
|
+
client.subscribeToFetchActivity((n) => fetching.push(n));
|
|
215
|
+
client.subscribeToSyncHealth((h) => health.push(h.status));
|
|
216
|
+
runtime.emit({ type: 'tray:changed', count: 2 });
|
|
217
|
+
runtime.emit({ type: 'activity:changed', fetching: 3, pending: 0 });
|
|
218
|
+
runtime.emit({ type: 'health:changed', health: { ...client.syncHealth, status: 'degraded' } });
|
|
219
|
+
runtime.emit({ type: 'query:evicted', hash });
|
|
220
|
+
expect(tray).toEqual([0, 2]);
|
|
221
|
+
expect(fetching).toEqual([0, 3]);
|
|
222
|
+
expect(health).toEqual(['healthy', 'degraded']);
|
|
223
|
+
const pending: number[] = [];
|
|
224
|
+
client.subscribeToPendingMutations((n) => pending.push(n));
|
|
225
|
+
runtime.emit({ type: 'activity:changed', fetching: 0, pending: 4 });
|
|
226
|
+
expect(pending).toEqual([0, 4]);
|
|
227
|
+
});
|
|
228
|
+
it('gates queryRaw/useRemote/remoteQuery behind allowRawRemote when the schema policy turns the allowlist on', async () => {
|
|
229
|
+
const services = fakeServiceBundle<any>();
|
|
230
|
+
const a = fakeAdapters({});
|
|
231
|
+
const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
|
|
232
|
+
const withPolicy = (mode: string | undefined, allowRawRemote?: boolean) =>
|
|
233
|
+
new Sp00kyClient<any>(
|
|
234
|
+
{ ...config, schema: mode === undefined ? schema : { ...schema, policy: { queryAllowlist: mode } }, allowRawRemote } as any,
|
|
235
|
+
{ services, runtime }
|
|
236
|
+
);
|
|
237
|
+
const msg = (m: string) => `sp00ky: ${m} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`;
|
|
238
|
+
for (const mode of ['warn', 'enforce']) {
|
|
239
|
+
const gated = withPolicy(mode);
|
|
240
|
+
await expect(gated.queryRaw('SELECT * FROM thing', {}, '10m')).rejects.toThrow(msg('queryRaw'));
|
|
241
|
+
await expect(gated.useRemote(() => 1)).rejects.toThrow(msg('useRemote'));
|
|
242
|
+
await expect(gated.remoteQuery('RETURN 1')).rejects.toThrow(msg('remoteQuery'));
|
|
243
|
+
expect(a.names().filter((n) => n === 'remote.query')).toEqual([]);
|
|
244
|
+
}
|
|
245
|
+
for (const client of [withPolicy('enforce', true), withPolicy('off'), withPolicy(undefined)]) {
|
|
246
|
+
await expect(client.useRemote(() => 1)).resolves.toBe(1);
|
|
247
|
+
await expect(client.remoteQuery('RETURN 1')).resolves.toBeDefined();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('honours config knobs when building the saga env', () => {
|
|
252
|
+
const services = fakeServiceBundle<any>();
|
|
253
|
+
const a = fakeAdapters();
|
|
254
|
+
const runtime = new Runtime({ env: defaultEnv(schema), adapters: a.adapters, logger: services.logger, tabId: 't' });
|
|
255
|
+
const tuned = new Sp00kyClient<any>(
|
|
256
|
+
{ ...config, database: { ...config.database, queryTimeoutMs: 5 }, syncHealth: false, pushTimeoutMs: 7, refSyncIntervalMs: 9, streamDebounceTime: 11, enableAnonymousLiveQueries: true, sharedTabs: true } as any,
|
|
257
|
+
{ services, runtime, env: { defaultTtlMs: 1 } }
|
|
258
|
+
);
|
|
259
|
+
expect((tuned as any).env).toMatchObject({ remoteTimeoutMs: 5, degradeAfter: 0, pushTimeoutMs: 7, pollBaseMs: 9, materializeDebounceMs: 11, anonLive: true, defaultTtlMs: 1 });
|
|
260
|
+
const degraded = new Sp00kyClient<any>({ ...config, syncHealth: { degradeAfterConsecutiveFailures: 2 } } as any, { services, runtime });
|
|
261
|
+
expect((degraded as any).env.degradeAfter).toBe(2);
|
|
262
|
+
expect(tuned.tabRole).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|