@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.321
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 +2034 -137
- package/dist/index.js +15236 -2467
- package/dist/live.d.ts +141 -0
- package/dist/live.js +733 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +365 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +379 -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 +1821 -38
- package/dist/worker-edges.js +470 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +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 +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +180 -0
- package/src/boot/boot.saga.ts +142 -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.test.ts +60 -0
- package/src/client/services.ts +418 -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 +163 -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 +501 -0
- package/src/live/index.ts +1039 -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 +169 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +466 -19
- 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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +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 +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +337 -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/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +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 +311 -0
- package/src/query/register.saga.ts +229 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +112 -0
- package/src/query/sql.ts +149 -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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +273 -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 +145 -0
- package/src/services/database/sqlite-plan-sql.ts +182 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +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 +783 -68
- 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 +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +175 -0
- package/src/state/selectors.ts +222 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +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 +78 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { Uuid } from 'surrealdb';
|
|
2
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
3
|
+
import type { Sp00kyConfig, PersistenceClient } from '../types';
|
|
4
|
+
import type { Logger } from '../services/logger/index';
|
|
5
|
+
import { LogTap, 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 { sha256Hex } from '../utils/sha256';
|
|
31
|
+
import { leaveViewsBeacon, listRefLive } from '../query/sql';
|
|
32
|
+
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
33
|
+
import { mintMutationId } from '../mutation/mutation-id';
|
|
34
|
+
import { BucketHandle, bucketContentToBlob } from './bucket-handle';
|
|
35
|
+
|
|
36
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
37
|
+
|
|
38
|
+
export function readBootBucketHint(): string | null {
|
|
39
|
+
try {
|
|
40
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function writeBootBucketHint(bucketId: string): void {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
49
|
+
} catch {
|
|
50
|
+
/* private-mode storage errors: boot falls back to the anon bucket */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const RELOAD_SALT_KEY = 'sp00ky:salt';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Take the salt this tab's previous page left on its way out, if it was minted
|
|
58
|
+
* for the same principal. A reload that keeps its salt re-registers every
|
|
59
|
+
* query onto the `_00_query` row it already had, which the SSP answers from
|
|
60
|
+
* the live view instead of building a new one.
|
|
61
|
+
*
|
|
62
|
+
* Taken, not read: the key is absent while a page lives, so a duplicated tab
|
|
63
|
+
* (which copies sessionStorage) mints its own salt rather than sharing this
|
|
64
|
+
* tab's remote views.
|
|
65
|
+
*/
|
|
66
|
+
export function takeReloadSalt(authId: string | null): { salt: string; sessionIds: string[] } | null {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
69
|
+
const raw = sessionStorage.getItem(RELOAD_SALT_KEY);
|
|
70
|
+
if (raw === null) return null;
|
|
71
|
+
sessionStorage.removeItem(RELOAD_SALT_KEY);
|
|
72
|
+
const left = JSON.parse(raw) as { salt?: unknown; authId?: unknown; sessionIds?: unknown };
|
|
73
|
+
if (typeof left.salt !== 'string' || left.salt === '' || (left.authId ?? null) !== authId) return null;
|
|
74
|
+
const sessionIds = Array.isArray(left.sessionIds) ? left.sessionIds.filter((sid): sid is string => typeof sid === 'string') : [];
|
|
75
|
+
return { salt: left.salt, sessionIds };
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Leave this page's salt for the next page in this tab; see {@link takeReloadSalt}.
|
|
83
|
+
*
|
|
84
|
+
* The session ids of this page's sockets go with it. The views the next page
|
|
85
|
+
* re-registers keep the edges this page's sockets stamped, and the anonymous
|
|
86
|
+
* LIVE only hears edges stamped by a session it knows (see `listRefLive`).
|
|
87
|
+
*/
|
|
88
|
+
export function leaveReloadSalt(salt: string, authId: string | null, sessionIds: ReadonlyArray<string> = []): void {
|
|
89
|
+
try {
|
|
90
|
+
if (typeof sessionStorage !== 'undefined')
|
|
91
|
+
sessionStorage.setItem(RELOAD_SALT_KEY, JSON.stringify({ salt, authId, sessionIds }));
|
|
92
|
+
} catch {
|
|
93
|
+
/* private-mode storage errors: the next page mints a fresh salt */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
|
|
98
|
+
// it; re-exported here for existing imports.
|
|
99
|
+
export { sha256Hex };
|
|
100
|
+
|
|
101
|
+
export function mintSalt(): string {
|
|
102
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
103
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
104
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Everything the facade talks to besides the runtime. */
|
|
108
|
+
export interface Services<S extends SchemaStructure> {
|
|
109
|
+
logger: Logger;
|
|
110
|
+
/** What the logger wrote, for the DevTools Logs tab. Absent in test fakes. */
|
|
111
|
+
logTap?: LogTap;
|
|
112
|
+
local: LocalStore;
|
|
113
|
+
remote: RemoteDatabaseService;
|
|
114
|
+
connectionSupervisor: ConnectionSupervisor;
|
|
115
|
+
blobs: BlobCache;
|
|
116
|
+
persistence: PersistenceClient;
|
|
117
|
+
streamProcessor: StreamProcessorService;
|
|
118
|
+
migrator: LocalMigrator;
|
|
119
|
+
crdt: CrdtManager;
|
|
120
|
+
auth: AuthService<S>;
|
|
121
|
+
tabs: TabsCoordinator | null;
|
|
122
|
+
tabsUnsupportedReason: string | undefined;
|
|
123
|
+
tabId: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the services need back from the runtime once it exists. */
|
|
127
|
+
export interface ServiceHost {
|
|
128
|
+
dispatch(event: RuntimeEvent): void;
|
|
129
|
+
/** Called by the tabs coordinator hooks with the hub / forwarder to relay through. */
|
|
130
|
+
setTabsChannel(channel: { hub: LeaderSyncHub | null; forwarder: SyncForwarder | null }): void;
|
|
131
|
+
/** Late-bound modules the boot saga starts (feature flags, app releases). */
|
|
132
|
+
initModules(): void;
|
|
133
|
+
detachWindow(): void;
|
|
134
|
+
setDetachWindow(fn: () => void): void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createServices<S extends SchemaStructure>(config: Sp00kyConfig<S>): Services<S> {
|
|
138
|
+
const logTap = new LogTap(config.logLevel ?? 'info');
|
|
139
|
+
const logger = createLogger(config.logLevel ?? 'info', config.otelTransmit, logTap);
|
|
140
|
+
if (config.crdt) void preloadLoro();
|
|
141
|
+
const tabsSupport = detectSharedTabsSupport(config);
|
|
142
|
+
const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
|
|
143
|
+
const remote = new RemoteDatabaseService(config.database, logger);
|
|
144
|
+
const connectionSupervisor = new ConnectionSupervisor(remote, logger);
|
|
145
|
+
const rawBucket = (name: string) => new BucketHandle(name, remote, null, { blurhash: config.blurhash, logger });
|
|
146
|
+
const blobs = createBlobCache({
|
|
147
|
+
local,
|
|
148
|
+
namespace: ANON_USER_ID,
|
|
149
|
+
logger,
|
|
150
|
+
fetchRemote: async (key) => bucketContentToBlob(await rawBucket(key.bucket).get(key.path)),
|
|
151
|
+
headRemote: (key) => rawBucket(key.bucket).head(key.path),
|
|
152
|
+
maxBytes: config.blobCache?.maxBytes,
|
|
153
|
+
store: config.blobCache?.enabled === false ? new MemoryBlobStore(ANON_USER_ID) : undefined,
|
|
154
|
+
});
|
|
155
|
+
let persistence: PersistenceClient;
|
|
156
|
+
if (config.persistenceClient === 'surrealdb') persistence = new SurrealDBPersistenceClient(local, logger);
|
|
157
|
+
else if (config.persistenceClient === 'localstorage' || !config.persistenceClient) persistence = new LocalStoragePersistenceClient(logger);
|
|
158
|
+
else persistence = config.persistenceClient;
|
|
159
|
+
persistence = new ResilientPersistenceClient(persistence, logger);
|
|
160
|
+
const streamProcessor = new StreamProcessorService(new EventSystem(['stream_update']), local, logger);
|
|
161
|
+
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === 'sqlite', config.circuitCheckpointMs);
|
|
162
|
+
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
163
|
+
const migrator = new LocalMigrator(local, logger);
|
|
164
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 500);
|
|
165
|
+
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
166
|
+
const tabId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
167
|
+
return {
|
|
168
|
+
logger,
|
|
169
|
+
logTap,
|
|
170
|
+
local,
|
|
171
|
+
remote,
|
|
172
|
+
connectionSupervisor,
|
|
173
|
+
blobs,
|
|
174
|
+
persistence,
|
|
175
|
+
streamProcessor,
|
|
176
|
+
migrator,
|
|
177
|
+
crdt,
|
|
178
|
+
auth,
|
|
179
|
+
tabs: null,
|
|
180
|
+
tabsUnsupportedReason: tabsSupport.supported ? undefined : (tabsSupport as { reason: string }).reason,
|
|
181
|
+
tabId,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The shared-tabs coordinator, wired so role changes and relays become runtime events. */
|
|
186
|
+
export function createTabsCoordinator<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost): TabsCoordinator {
|
|
187
|
+
const engine = s.local as SqliteCacheEngine;
|
|
188
|
+
const hooks: CoordinatorHooks = {
|
|
189
|
+
adoptOwner: (bucketId, opts) =>
|
|
190
|
+
engine.adoptOwner(bucketId, { workerLockName: opts.workerLockName, allowMemoryFallback: opts.allowMemoryFallback, resumeHeld: opts.resumeHeld }),
|
|
191
|
+
adoptAttached: (dbPort, snapshot) => engine.adoptAttached(dbPort, snapshot, (reason) => engine.onLeaderLost(reason)),
|
|
192
|
+
releaseOwnership: () => engine.releaseOwnership(),
|
|
193
|
+
onLeaderLost: (reason) => engine.onLeaderLost(reason),
|
|
194
|
+
exposeClientPort: (clientId, port) => engine.exposeClientPort(clientId, port),
|
|
195
|
+
removeClientPort: (clientId) => engine.removeClientPort(clientId),
|
|
196
|
+
becomeSyncLeader: (hub) => {
|
|
197
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
198
|
+
hub.onFollowerMessage = (_tabId, msg) => host.dispatch({ type: 'TabMessage', message: followerToRuntime(msg) });
|
|
199
|
+
host.setTabsChannel({ hub, forwarder: null });
|
|
200
|
+
host.dispatch({ type: 'TabRole', role: 'leader' });
|
|
201
|
+
},
|
|
202
|
+
resumeSyncLeaderDuties: async () => {
|
|
203
|
+
host.dispatch({ type: 'Drain' });
|
|
204
|
+
host.dispatch({ type: 'LiveStart' });
|
|
205
|
+
},
|
|
206
|
+
becomeSyncFollower: (forwarder) => {
|
|
207
|
+
s.streamProcessor.setPersistenceEnabled(false);
|
|
208
|
+
forwarder.onLeaderMessage = (msg) => host.dispatch({ type: 'TabMessage', message: leaderToRuntime(msg) });
|
|
209
|
+
host.setTabsChannel({ hub: null, forwarder });
|
|
210
|
+
host.dispatch({ type: 'TabRole', role: 'follower' });
|
|
211
|
+
},
|
|
212
|
+
becomeSyncSolo: () => {
|
|
213
|
+
s.streamProcessor.setPersistenceEnabled(true);
|
|
214
|
+
host.setTabsChannel({ hub: null, forwarder: null });
|
|
215
|
+
host.dispatch({ type: 'TabRole', role: 'solo' });
|
|
216
|
+
},
|
|
217
|
+
currentStorageHealth: () => s.local.storageHealth ?? { status: 'unknown', fallback: false },
|
|
218
|
+
};
|
|
219
|
+
return new TabsCoordinator({
|
|
220
|
+
tabId: s.tabId,
|
|
221
|
+
fingerprint: computeTabsFingerprint({
|
|
222
|
+
coreVersion: typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown',
|
|
223
|
+
schemaHash: hash53(config.schemaSurql),
|
|
224
|
+
endpoint: config.database.endpoint ?? '',
|
|
225
|
+
namespace: config.database.namespace,
|
|
226
|
+
database: config.database.database,
|
|
227
|
+
}),
|
|
228
|
+
hooks,
|
|
229
|
+
logger: s.logger,
|
|
230
|
+
onLeaderPageHide: () => void engine.shutdownOwnedWorker(),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Wire-protocol messages become the runtime's tab messages. */
|
|
235
|
+
export function followerToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
236
|
+
switch (msg.type) {
|
|
237
|
+
case 'mutation-enqueued':
|
|
238
|
+
return { type: 'outbox-changed', mutationId: msg.mutationId };
|
|
239
|
+
case 'ingest':
|
|
240
|
+
return { type: 'ingest', records: msg.tuples };
|
|
241
|
+
case 'request-poll':
|
|
242
|
+
return { type: 'membership-dirty', hashes: [] };
|
|
243
|
+
default:
|
|
244
|
+
return msg;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function leaderToRuntime(msg: { type: string; [k: string]: unknown }): unknown {
|
|
249
|
+
switch (msg.type) {
|
|
250
|
+
case 'ingest-relay':
|
|
251
|
+
return { type: 'ingest', records: msg.tuples };
|
|
252
|
+
default:
|
|
253
|
+
return msg;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Edge decoding lives in a pure module (the wasm-free live feed uses it too);
|
|
258
|
+
// re-exported here for existing imports.
|
|
259
|
+
export { hashOfEdge, rowOfEdge };
|
|
260
|
+
|
|
261
|
+
/** Build the adapters the interpreter drives from the services. */
|
|
262
|
+
export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
|
|
263
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
264
|
+
const services: ServiceCalls = {
|
|
265
|
+
'hint.read': readBootBucketHint,
|
|
266
|
+
'hint.write': writeBootBucketHint,
|
|
267
|
+
'salt.take': (authId) => {
|
|
268
|
+
const left = takeReloadSalt(authId);
|
|
269
|
+
if (!left) return null;
|
|
270
|
+
s.remote.adoptSessionIds(left.sessionIds);
|
|
271
|
+
return left.salt;
|
|
272
|
+
},
|
|
273
|
+
'salt.leave': (salt, authId) => leaveReloadSalt(salt, authId, s.remote.sessionIds()),
|
|
274
|
+
'local.connect': (bucketId) => s.local.connect(bucketId),
|
|
275
|
+
'local.switchStore': (bucketId) => s.local.switchStore(bucketId),
|
|
276
|
+
'local.beginSwitch': () => s.local.beginSwitch(),
|
|
277
|
+
'local.currentBucketId': () => s.local.currentBucketId,
|
|
278
|
+
'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
|
|
279
|
+
'local.dropBucket': async (bucketId) => {
|
|
280
|
+
await s.local.dropBucket?.(bucketId);
|
|
281
|
+
},
|
|
282
|
+
'migrator.provision': () => s.migrator.provision(config.schemaSurql),
|
|
283
|
+
'blobs.start': async (bucketId) => {
|
|
284
|
+
s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
|
|
285
|
+
await s.blobs.start(bucketId);
|
|
286
|
+
},
|
|
287
|
+
'blobs.setNamespace': (bucketId) => s.blobs.setNamespace(bucketId),
|
|
288
|
+
'blobs.clear': () => s.blobs.clear(),
|
|
289
|
+
'ssp.init': () => s.streamProcessor.init(),
|
|
290
|
+
'ssp.setPermissions': () => s.streamProcessor.setPermissions(extractSelectPermissions(config.schemaSurql)),
|
|
291
|
+
'ssp.setSessionAuth': (authId, access) => s.streamProcessor.setSessionAuth(authId, access),
|
|
292
|
+
'ssp.prime': (pendingIds) =>
|
|
293
|
+
s.streamProcessor.primeFromLocal({
|
|
294
|
+
tables: [...config.schema.tables.map((t) => t.name), '_00_user_feature', '_00_app_release'],
|
|
295
|
+
schemaHash: String(hash53(config.schemaSurql)),
|
|
296
|
+
pendingIds: new Set(pendingIds),
|
|
297
|
+
onVersions: (_table, entries) => host.dispatch({ type: 'VersionsPrimed', entries }),
|
|
298
|
+
}),
|
|
299
|
+
'ssp.reset': () => s.streamProcessor.reset(),
|
|
300
|
+
'ssp.setPersistence': (enabled) => s.streamProcessor.setPersistenceEnabled(enabled),
|
|
301
|
+
'auth.restoreSession': () => s.auth.restoreSessionFromToken(),
|
|
302
|
+
'auth.init': () => s.auth.init(),
|
|
303
|
+
'auth.sessionAuthId': () => {
|
|
304
|
+
const id = s.auth.currentUser?.id;
|
|
305
|
+
if (!id) return null;
|
|
306
|
+
return typeof id === 'string' ? id : encodeRecordId(id);
|
|
307
|
+
},
|
|
308
|
+
'auth.access': () => s.auth.access,
|
|
309
|
+
'auth.token': () => s.auth.token,
|
|
310
|
+
'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
|
|
311
|
+
'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
|
|
312
|
+
'remote.connect': () => s.remote.connect(),
|
|
313
|
+
'remote.releaseViews': (ids) => {
|
|
314
|
+
const sql = leaveViewsBeacon(ids, s.remote.sessionIds());
|
|
315
|
+
if (sql) s.remote.beaconSql(sql);
|
|
316
|
+
},
|
|
317
|
+
'supervisor.start': () => s.connectionSupervisor.start(),
|
|
318
|
+
'tabs.start': (bucketId) => {
|
|
319
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
320
|
+
return s.tabs.start(bucketId);
|
|
321
|
+
},
|
|
322
|
+
'tabs.moveToBucket': (bucketId) => {
|
|
323
|
+
if (!s.tabs) throw new Error('shared tabs unavailable');
|
|
324
|
+
return s.tabs.moveToBucket(bucketId);
|
|
325
|
+
},
|
|
326
|
+
'crdt.setSessionId': (id) => s.crdt.setSessionId(id),
|
|
327
|
+
'crdt.closeAll': (flush) => s.crdt.closeAll({ flush }),
|
|
328
|
+
'persistence.set': (key, value) => s.persistence.set(key, value),
|
|
329
|
+
'features.init': () => {
|
|
330
|
+
for (const m of lateModules()) m.init();
|
|
331
|
+
},
|
|
332
|
+
'releases.init': () => undefined,
|
|
333
|
+
'window.attach': () => {
|
|
334
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
335
|
+
// Hidden is the last moment a page reliably gets to run: a reload or a
|
|
336
|
+
// closed tab follows it, and a backgrounded mobile tab may be killed
|
|
337
|
+
// without any further event. Pending debounced writes go out then.
|
|
338
|
+
const onPageHide = (event: PageTransitionEvent) => {
|
|
339
|
+
host.dispatch({ type: 'FlushPendingWrites' });
|
|
340
|
+
if (event.persisted) return;
|
|
341
|
+
host.dispatch({ type: 'PageHide' });
|
|
342
|
+
};
|
|
343
|
+
const onWake = () => {
|
|
344
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
345
|
+
host.dispatch({ type: 'HeartbeatNow' });
|
|
346
|
+
};
|
|
347
|
+
const onVisibility = () => {
|
|
348
|
+
if (document.visibilityState === 'hidden') host.dispatch({ type: 'FlushPendingWrites' });
|
|
349
|
+
else onWake();
|
|
350
|
+
};
|
|
351
|
+
window.addEventListener('pagehide', onPageHide);
|
|
352
|
+
window.addEventListener('online', onWake);
|
|
353
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
|
|
354
|
+
host.setDetachWindow(() => {
|
|
355
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
356
|
+
window.removeEventListener('online', onWake);
|
|
357
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
|
|
358
|
+
});
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
local: s.local,
|
|
363
|
+
remote: {
|
|
364
|
+
queryResponses: (sql, vars) => s.remote.queryResponses(sql, vars),
|
|
365
|
+
live: async (table, onChange) => {
|
|
366
|
+
// `FETCH out` resolves the edge's target into the notification, so the
|
|
367
|
+
// body arrives with the doorbell instead of costing a second round
|
|
368
|
+
// trip. The clause is only ever added, never relied on: a server that
|
|
369
|
+
// ignores it, or a row the session cannot read, simply yields no
|
|
370
|
+
// inline row and the fetch path takes over.
|
|
371
|
+
const stmt = listRefLive(table, { inlineBodies: config.liveInlineBodies === true, sessionIds: s.remote.sessionIds() });
|
|
372
|
+
const [uuid] = await s.remote.query<[Uuid]>(stmt.sql, stmt.vars);
|
|
373
|
+
const live = await s.remote.getClient().liveOf(uuid);
|
|
374
|
+
live.subscribe((message) => {
|
|
375
|
+
if (message.action === 'KILLED') return;
|
|
376
|
+
const hash = hashOfEdge(message.value);
|
|
377
|
+
if (!hash) return;
|
|
378
|
+
// Not on DELETE: that edge is a row leaving the view, and for a row
|
|
379
|
+
// that was actually deleted the join yields nothing anyway. Landing
|
|
380
|
+
// a body there would only ever write back what is going away.
|
|
381
|
+
const row = message.action === 'DELETE' ? null : rowOfEdge(message.value);
|
|
382
|
+
onChange([hash], row ? [row] : undefined);
|
|
383
|
+
});
|
|
384
|
+
return String(uuid);
|
|
385
|
+
},
|
|
386
|
+
kill: async (uuid) => {
|
|
387
|
+
await s.remote.query('KILL $u', { u: uuid });
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
ssp: {
|
|
391
|
+
registerQueryPlan: (plan) => s.streamProcessor.registerQueryPlan(plan) as StreamUpdate | undefined,
|
|
392
|
+
unregisterQueryPlan: (hash) => s.streamProcessor.unregisterQueryPlan(hash),
|
|
393
|
+
ingestMany: (records) => s.streamProcessor.ingestMany(records),
|
|
394
|
+
},
|
|
395
|
+
timers: {
|
|
396
|
+
set: (key, ms, fire) => {
|
|
397
|
+
const prev = timers.get(key);
|
|
398
|
+
if (prev) clearTimeout(prev);
|
|
399
|
+
timers.set(
|
|
400
|
+
key,
|
|
401
|
+
setTimeout(() => {
|
|
402
|
+
timers.delete(key);
|
|
403
|
+
fire();
|
|
404
|
+
}, ms)
|
|
405
|
+
);
|
|
406
|
+
},
|
|
407
|
+
clear: (key) => {
|
|
408
|
+
const prev = timers.get(key);
|
|
409
|
+
if (prev) clearTimeout(prev);
|
|
410
|
+
timers.delete(key);
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
clock: { now: () => Date.now() },
|
|
414
|
+
ids: { mutation: () => mintMutationId(s.tabId), salt: mintSalt },
|
|
415
|
+
hash: sha256Hex,
|
|
416
|
+
services,
|
|
417
|
+
};
|
|
418
|
+
}
|