@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.271
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 +2279 -81
- package/dist/index.js +13490 -2585
- 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 +939 -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 +528 -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 +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +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 +322 -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 +283 -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 +603 -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,528 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb';
|
|
2
|
+
import type {
|
|
3
|
+
BackendNames,
|
|
4
|
+
BackendRoutes,
|
|
5
|
+
FinalQuery,
|
|
6
|
+
GetTable,
|
|
7
|
+
InnerQuery,
|
|
8
|
+
QueryOptions,
|
|
9
|
+
RoutePayload,
|
|
10
|
+
SchemaStructure,
|
|
11
|
+
TableModel,
|
|
12
|
+
TableNames,
|
|
13
|
+
BucketNames,
|
|
14
|
+
} from '@spooky-sync/query-builder';
|
|
15
|
+
import { QueryBuilder } from '@spooky-sync/query-builder';
|
|
16
|
+
import type {
|
|
17
|
+
PreloadOptions,
|
|
18
|
+
QueryAuthorityCallback,
|
|
19
|
+
QueryStatusCallback,
|
|
20
|
+
QueryTimeToLive,
|
|
21
|
+
RunOptions,
|
|
22
|
+
Sp00kyConfig,
|
|
23
|
+
Sp00kyQueryResultPromise,
|
|
24
|
+
StorageHealth,
|
|
25
|
+
SyncHealth,
|
|
26
|
+
UpdateOptions,
|
|
27
|
+
} from '../types';
|
|
28
|
+
import type { TabRole } from '../services/tabs/protocol';
|
|
29
|
+
import { StreamProcessorService } from '../services/stream-processor/index';
|
|
30
|
+
import type { StreamUpdateReceiver } from '../services/stream-processor/index';
|
|
31
|
+
import { AuthService } from '../modules/auth/index';
|
|
32
|
+
import { CrdtField } from '../modules/crdt/index';
|
|
33
|
+
import { FeatureFlagModule, FeatureFlagHandle } from '../modules/feature-flag/index';
|
|
34
|
+
import type { FeatureFlagOptions, FeatureFlagOverride } from '../modules/feature-flag/index';
|
|
35
|
+
import { AppReleaseModule, AppReleaseHandle } from '../modules/app-release/index';
|
|
36
|
+
import type { AppReleaseOptions } from '../modules/app-release/index';
|
|
37
|
+
import { DevToolsService } from '../modules/devtools/index';
|
|
38
|
+
import type { DevToolsQuerySource } from '../modules/devtools/index';
|
|
39
|
+
import { parseQueryParams, generateId } from '../utils/index';
|
|
40
|
+
import type { OutEvent, RuntimeEvent } from '../kernel/events';
|
|
41
|
+
import type { ClientState } from '../state/client-state';
|
|
42
|
+
import * as R from '../state/reducers';
|
|
43
|
+
import { pendingMutationCount, fetchingQueryCount, phaseTimings, toQueryState } from '../state/selectors';
|
|
44
|
+
import { defaultEnv, type SagaEnv } from '../query/env';
|
|
45
|
+
import { registerLocal, type RegisterInput } from '../query/register.saga';
|
|
46
|
+
import { evictQuery } from '../query/lifecycle.saga';
|
|
47
|
+
import { write } from '../mutation/write.saga';
|
|
48
|
+
import { discardFailed, listFailed, retryFailed } from '../mutation/tray.saga';
|
|
49
|
+
import type { FailedMutationRow } from '../mutation/rows';
|
|
50
|
+
import { buildJobRecord } from '../mutation/jobs';
|
|
51
|
+
import { boot } from '../boot/boot.saga';
|
|
52
|
+
import { preload } from '../boot/preload.saga';
|
|
53
|
+
import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../kernel/constants';
|
|
54
|
+
import { Runtime } from './runtime';
|
|
55
|
+
import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
|
|
56
|
+
import { BucketHandle } from './bucket-handle';
|
|
57
|
+
import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
|
|
58
|
+
import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
|
|
59
|
+
|
|
60
|
+
const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
|
|
61
|
+
|
|
62
|
+
export interface Sp00kyClientDeps<S extends SchemaStructure> {
|
|
63
|
+
services?: Services<S>;
|
|
64
|
+
runtime?: Runtime;
|
|
65
|
+
env?: Partial<SagaEnv>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The public client. Every method is a thin translation into a saga run, a
|
|
70
|
+
* state read, or a subscription on the runtime; the engine's behaviour lives
|
|
71
|
+
* in the sagas.
|
|
72
|
+
*/
|
|
73
|
+
export class Sp00kyClient<S extends SchemaStructure> {
|
|
74
|
+
public readonly auth: AuthService<S>;
|
|
75
|
+
public readonly streamProcessor: StreamProcessorService;
|
|
76
|
+
|
|
77
|
+
private readonly services: Services<S>;
|
|
78
|
+
private readonly env: SagaEnv;
|
|
79
|
+
private readonly runtime: Runtime;
|
|
80
|
+
private readonly featureFlags: FeatureFlagModule<S>;
|
|
81
|
+
private readonly appReleases: AppReleaseModule<S>;
|
|
82
|
+
/** Teardown for subscriptions the client itself owns. */
|
|
83
|
+
private readonly disposers: Array<() => void> = [];
|
|
84
|
+
/** Assigned in `wireAdapters`, which the constructor always runs. */
|
|
85
|
+
private impersonationBanner!: ImpersonationBanner;
|
|
86
|
+
private readonly devTools: DevToolsService;
|
|
87
|
+
private hub: LeaderSyncHub | null = null;
|
|
88
|
+
private forwarder: SyncForwarder | null = null;
|
|
89
|
+
private detachWindow: (() => void) | null = null;
|
|
90
|
+
private sharedActive = false;
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private readonly config: Sp00kyConfig<S>,
|
|
94
|
+
deps: Sp00kyClientDeps<S> = {}
|
|
95
|
+
) {
|
|
96
|
+
this.services = deps.services ?? createServices(config);
|
|
97
|
+
const s = this.services;
|
|
98
|
+
this.env = defaultEnv(config.schema, {
|
|
99
|
+
anonLive: config.enableAnonymousLiveQueries === true,
|
|
100
|
+
remoteTimeoutMs: Math.max(0, config.database.queryTimeoutMs ?? 60_000),
|
|
101
|
+
pushTimeoutMs: config.pushTimeoutMs ?? PUSH_TIMEOUT_MS,
|
|
102
|
+
outboxBatchSize: OUTBOX_BATCH_SIZE,
|
|
103
|
+
degradeAfter: config.syncHealth === false ? 0 : (config.syncHealth?.degradeAfterConsecutiveFailures ?? DEGRADE_AFTER_FAILURES),
|
|
104
|
+
materializeDebounceMs: config.streamDebounceTime ?? 50,
|
|
105
|
+
pollBaseMs: config.refSyncIntervalMs && config.refSyncIntervalMs > 0 ? config.refSyncIntervalMs : 500,
|
|
106
|
+
...deps.env,
|
|
107
|
+
});
|
|
108
|
+
const host: ServiceHost = {
|
|
109
|
+
dispatch: (event) => void this.runtime.dispatch(event),
|
|
110
|
+
setTabsChannel: ({ hub, forwarder }) => {
|
|
111
|
+
this.hub = hub;
|
|
112
|
+
this.forwarder = forwarder;
|
|
113
|
+
this.sharedActive = hub !== null || forwarder !== null;
|
|
114
|
+
},
|
|
115
|
+
initModules: () => undefined,
|
|
116
|
+
detachWindow: () => this.detachWindow?.(),
|
|
117
|
+
setDetachWindow: (fn) => (this.detachWindow = fn),
|
|
118
|
+
};
|
|
119
|
+
if (!deps.runtime && s.tabs === null && s.tabsUnsupportedReason === undefined) {
|
|
120
|
+
s.tabs = createTabsCoordinator(config, s, host);
|
|
121
|
+
}
|
|
122
|
+
this.runtime =
|
|
123
|
+
deps.runtime ??
|
|
124
|
+
new Runtime({
|
|
125
|
+
env: this.env,
|
|
126
|
+
adapters: createAdapters(config, s, host, () => [this.featureFlags, this.appReleases]),
|
|
127
|
+
logger: s.logger,
|
|
128
|
+
tabId: s.tabId,
|
|
129
|
+
});
|
|
130
|
+
this.auth = s.auth;
|
|
131
|
+
this.streamProcessor = s.streamProcessor;
|
|
132
|
+
|
|
133
|
+
const liveQueryPort = {
|
|
134
|
+
query: (table: string, surql: string, params: Record<string, unknown>, ttl: QueryTimeToLive) =>
|
|
135
|
+
this.runtime.run(registerLocal(this.env, { tableName: table, surql, params, ttl })),
|
|
136
|
+
subscribe: (hash: string, cb: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }) =>
|
|
137
|
+
this.runtime.subscribe(hash, cb, options),
|
|
138
|
+
};
|
|
139
|
+
const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
|
|
140
|
+
this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
|
|
141
|
+
this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
|
|
142
|
+
|
|
143
|
+
const querySource: DevToolsQuerySource = {
|
|
144
|
+
getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
|
|
145
|
+
getQueryById: (id) => {
|
|
146
|
+
const entry = [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(id));
|
|
147
|
+
return entry ? toQueryState(entry) : undefined;
|
|
148
|
+
},
|
|
149
|
+
phaseTimings: (q) => {
|
|
150
|
+
const entry = this.runtime.state.queries.get(q.config.id.id as string) ?? [...this.runtime.state.queries.values()].find((e) => String(e.def.id) === String(q.config.id));
|
|
151
|
+
return entry ? phaseTimings(entry) : ({} as never);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
this.devTools = new DevToolsService(s.local, s.remote, s.logger, config.schema, s.auth, querySource);
|
|
155
|
+
this.devTools.setFeatureFlagOverrides(this.featureFlags);
|
|
156
|
+
s.streamProcessor.addReceiver(this.devTools);
|
|
157
|
+
this.devTools.setBlobInfoProvider(() => s.blobs.stats());
|
|
158
|
+
if (config.sharedTabs) {
|
|
159
|
+
this.devTools.setTabsInfoProvider(() => {
|
|
160
|
+
const c = s.tabs;
|
|
161
|
+
if (!this.sharedActive || !c) return { active: false, reason: s.tabsUnsupportedReason ?? 'fell-back' };
|
|
162
|
+
return {
|
|
163
|
+
active: true,
|
|
164
|
+
role: c.role,
|
|
165
|
+
tabId: c.tabId,
|
|
166
|
+
leadershipId: c.leadershipId,
|
|
167
|
+
leaderTabId: c.role === 'leader' ? c.tabId : c.leaderTabId,
|
|
168
|
+
...(this.hub ? { followers: this.hub.followerCount, relayedBatches: this.hub.relayedBatches } : {}),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
this.wireAdapters();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Adapter callbacks become runtime events; runtime events reach the legacy services. */
|
|
176
|
+
private wireAdapters(): void {
|
|
177
|
+
const s = this.services;
|
|
178
|
+
const receiver: StreamUpdateReceiver = { onStreamUpdate: (update) => void this.runtime.dispatch({ type: 'StreamUpdate', update }) };
|
|
179
|
+
s.streamProcessor.addReceiver(receiver);
|
|
180
|
+
s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
|
|
181
|
+
s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
|
|
182
|
+
// Whoever is looking at the page should be able to tell, and leave. The
|
|
183
|
+
// app can restyle this, replace it (`mode: 'custom'`, with a fallback if
|
|
184
|
+
// its own never renders) or take the job over entirely (`mode: 'none'`).
|
|
185
|
+
this.impersonationBanner = new ImpersonationBanner(
|
|
186
|
+
() => s.auth.stopImpersonating(),
|
|
187
|
+
this.config.impersonationBanner
|
|
188
|
+
);
|
|
189
|
+
const banner = this.impersonationBanner;
|
|
190
|
+
this.disposers.push(
|
|
191
|
+
s.auth.subscribeImpersonation((info) => banner.update(info)),
|
|
192
|
+
() => banner.unmount()
|
|
193
|
+
);
|
|
194
|
+
this.runtime.on('tabs:broadcast', (e) => {
|
|
195
|
+
if (e.type !== 'tabs:broadcast') return;
|
|
196
|
+
const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
|
|
197
|
+
if (this.hub) {
|
|
198
|
+
if (msg.type === 'ingest') this.hub.relayIngest(msg.records as never);
|
|
199
|
+
else this.hub.broadcast(msg as never);
|
|
200
|
+
} else if (this.forwarder) {
|
|
201
|
+
if (msg.type === 'ingest') this.forwarder.ingest(msg.records as never);
|
|
202
|
+
else if (msg.type === 'outbox-changed' && msg.mutationId) this.forwarder.mutationEnqueued(msg.mutationId);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
this.runtime.on('tabs:sendTo', (e) => {
|
|
206
|
+
if (e.type === 'tabs:sendTo' && this.hub) this.hub.sendTo(e.tabId, e.message as never);
|
|
207
|
+
});
|
|
208
|
+
this.runtime.on('*', (e) => this.forwardToDevTools(e));
|
|
209
|
+
s.local.getEvents().subscribe('DATABASE_LOCAL_QUERY', (event: any) => this.devTools.logEvent('LOCAL_QUERY', event.payload));
|
|
210
|
+
s.remote.getEvents().subscribe('DATABASE_REMOTE_QUERY', (event: any) => this.devTools.logEvent('REMOTE_QUERY', event.payload));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private forwardToDevTools(e: OutEvent): void {
|
|
214
|
+
switch (e.type) {
|
|
215
|
+
case 'query:status':
|
|
216
|
+
this.devTools.logEvent('QUERY_STATUS_CHANGED', { queryHash: e.hash, status: e.status });
|
|
217
|
+
break;
|
|
218
|
+
case 'query:authority':
|
|
219
|
+
this.devTools.logEvent('QUERY_AUTHORITY_CHANGED', { queryHash: e.hash, known: e.known });
|
|
220
|
+
break;
|
|
221
|
+
case 'query:view-lost':
|
|
222
|
+
this.devTools.logEvent('QUERY_VIEW_LOST', { queryHash: e.hash });
|
|
223
|
+
break;
|
|
224
|
+
case 'mutation:event':
|
|
225
|
+
this.devTools.onMutation([e.event]);
|
|
226
|
+
break;
|
|
227
|
+
case 'devtools':
|
|
228
|
+
this.devTools.logEvent(e.name, e.data);
|
|
229
|
+
break;
|
|
230
|
+
default:
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- lifecycle ---------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
async init(): Promise<void> {
|
|
238
|
+
await this.runtime.run(boot(this.env, { sharedTabs: this.services.tabs !== null }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async close(): Promise<void> {
|
|
242
|
+
const s = this.services;
|
|
243
|
+
s.connectionSupervisor.dispose();
|
|
244
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
245
|
+
this.runtime.dispose();
|
|
246
|
+
await this.featureFlags.closeAll();
|
|
247
|
+
await this.appReleases.closeAll();
|
|
248
|
+
s.crdt.closeAll();
|
|
249
|
+
s.crdt.dispose();
|
|
250
|
+
await s.blobs.close().catch(() => {});
|
|
251
|
+
await s.streamProcessor.checkpoint('close').catch(() => {});
|
|
252
|
+
if (s.tabs) await s.tabs.stop();
|
|
253
|
+
this.detachWindow?.();
|
|
254
|
+
this.detachWindow = null;
|
|
255
|
+
await s.local.close();
|
|
256
|
+
await s.remote.close();
|
|
257
|
+
s.streamProcessor.dispose();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
isLocalReady(): boolean {
|
|
261
|
+
return this.runtime.state.localReady;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- queries -----------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
query<Table extends TableNames<S>>(
|
|
267
|
+
table: Table,
|
|
268
|
+
options: QueryOptions<TableModel<GetTable<S, Table>>, false>,
|
|
269
|
+
ttl: QueryTimeToLive = '10m'
|
|
270
|
+
): QueryBuilder<S, Table, Sp00kyQueryResultPromise> {
|
|
271
|
+
return new QueryBuilder<S, Table, Sp00kyQueryResultPromise>(this.config.schema, table, async (q) => ({ hash: await this.initQuery(table, q, ttl) }), options);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private registerInput(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): RegisterInput {
|
|
275
|
+
const tableSchema = this.config.schema.tables.find((t) => t.name === table);
|
|
276
|
+
if (!tableSchema) throw new Error(`Table ${table} not found`);
|
|
277
|
+
return {
|
|
278
|
+
tableName: table,
|
|
279
|
+
surql: q.selectQuery.query,
|
|
280
|
+
params: parseQueryParams(tableSchema.columns, q.selectQuery.vars ?? {}),
|
|
281
|
+
ttl,
|
|
282
|
+
plan: q.selectQuery.plan,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private initQuery(table: string, q: InnerQuery<any, any, any>, ttl: QueryTimeToLive): Promise<string> {
|
|
287
|
+
return this.runtime.run(registerLocal(this.env, this.registerInput(table, q, ttl)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The raw remote escape hatches bypass the query builder, so their shapes
|
|
292
|
+
* can never be on the server's query allowlist. When the generated schema
|
|
293
|
+
* says the allowlist is on, refuse them unless the app opted in explicitly.
|
|
294
|
+
*/
|
|
295
|
+
private assertRawRemoteAllowed(method: string): void {
|
|
296
|
+
const mode = this.config.schema.policy?.queryAllowlist;
|
|
297
|
+
if ((mode === 'warn' || mode === 'enforce') && !this.config.allowRawRemote) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`sp00ky: ${method} is disabled because sync.queryAllowlist is on for this schema; pass allowRawRemote: true in the client config to opt in`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async queryRaw(sql: string, params: Record<string, any>, ttl: QueryTimeToLive): Promise<string> {
|
|
305
|
+
this.assertRawRemoteAllowed('queryRaw');
|
|
306
|
+
const tableName = sql.split('FROM ')[1]?.split(' ')[0] ?? '';
|
|
307
|
+
return this.runtime.run(registerLocal(this.env, { tableName, surql: sql, params, ttl }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Preload = register a query nobody subscribes to. Resolved before on this
|
|
312
|
+
* device: returns at once. Never resolved: resolves when the server's
|
|
313
|
+
* membership and every body are local. `signal` aborts the wait.
|
|
314
|
+
*/
|
|
315
|
+
async preload(finalQuery: FinalQuery<S, any, any, any, any, any>, options?: PreloadOptions): Promise<void> {
|
|
316
|
+
const q = finalQuery.innerQuery;
|
|
317
|
+
await this.runtime.run(preload(this.env, this.registerInput(q.tableName, q, '10m')), { signal: options?.signal });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async subscribe(queryHash: string, callback: (records: Record<string, any>[]) => void, options?: { immediate?: boolean }): Promise<() => void> {
|
|
321
|
+
return this.runtime.subscribe(queryHash, callback, options);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
deregisterQuery(queryHash: string): void {
|
|
325
|
+
const entry = this.runtime.state.queries.get(queryHash);
|
|
326
|
+
if (!entry || entry.subscribers > 0) return;
|
|
327
|
+
void this.runtime.run(evictQuery(queryHash), { lane: { kind: 'serial', key: `mat:${queryHash}` } });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
subscribeQueryStatus(queryHash: string, callback: QueryStatusCallback, options?: { immediate?: boolean }): () => void {
|
|
331
|
+
return this.runtime.subscribeStatus(queryHash, callback, options);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
subscribeQueryAuthority(queryHash: string, callback: QueryAuthorityCallback, options?: { immediate?: boolean }): () => void {
|
|
335
|
+
return this.runtime.subscribeAuthority(queryHash, callback, options);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
reportFrontendTiming(queryHash: string, ms: number): void {
|
|
339
|
+
if (Number.isFinite(ms)) this.runtime.update(R.recordPhase(queryHash, 'frontend', ms));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- mutations ---------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
|
|
345
|
+
const out = await this.runtime.run(write(this.env, { kind: 'create', recordId: id, data }));
|
|
346
|
+
return (out.record ?? data) as T;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async update<T extends Record<string, unknown>>(_table: string, id: string, data: Partial<T>, options?: UpdateOptions): Promise<T> {
|
|
350
|
+
const out = await this.runtime.run(write(this.env, { kind: 'update', recordId: id, data: data as Record<string, unknown>, options }));
|
|
351
|
+
return (out.record ?? data) as T;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async delete(_table: string, id: string): Promise<void> {
|
|
355
|
+
await this.runtime.run(write(this.env, { kind: 'delete', recordId: id }));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void> {
|
|
359
|
+
const { tableName, record } = buildJobRecord(this.config.schema, String(backend), String(path), payload as Record<string, unknown>, options);
|
|
360
|
+
await this.create(`${tableName}:${generateId()}`, record);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- failed-writes tray -------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
get failedMutationCount(): number {
|
|
366
|
+
return this.runtime.state.failedCount;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
subscribeToFailedMutations(cb: (count: number) => void): () => void {
|
|
370
|
+
cb(this.runtime.state.failedCount);
|
|
371
|
+
return this.runtime.on('tray:changed', (e) => {
|
|
372
|
+
if (e.type === 'tray:changed') cb(e.count);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
listFailedMutations(): Promise<FailedMutationRow[]> {
|
|
377
|
+
return this.runtime.run(listFailed());
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async retryFailedMutation(mutationId: string): Promise<boolean> {
|
|
381
|
+
return this.runtime.run(retryFailed(this.env, mutationId), { lane: { kind: 'serial', key: 'tray' } });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async discardFailedMutation(mutationId: string): Promise<boolean> {
|
|
385
|
+
return this.runtime.run(discardFailed(mutationId), { lane: { kind: 'serial', key: 'tray' } });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---- observability -----------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
get tabRole(): TabRole | null {
|
|
391
|
+
return this.sharedActive && this.services.tabs ? this.services.tabs.role : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
get remoteClient(): Surreal {
|
|
395
|
+
return this.services.remote.getClient();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
get localClient(): unknown {
|
|
399
|
+
return this.services.local.getClient();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
get pendingMutationCount(): number {
|
|
403
|
+
return pendingMutationCount(this.runtime.state);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Kept for the e2e suite; the saga core has no LIVE retry ladder. */
|
|
407
|
+
get liveRetryCount(): number {
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
412
|
+
cb(this.pendingMutationCount);
|
|
413
|
+
return this.runtime.on('activity:changed', (e) => {
|
|
414
|
+
if (e.type === 'activity:changed') cb(e.pending);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
get fetchingQueryCount(): number {
|
|
419
|
+
return fetchingQueryCount(this.runtime.state);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
|
|
423
|
+
cb(this.fetchingQueryCount);
|
|
424
|
+
return this.runtime.on('activity:changed', (e) => {
|
|
425
|
+
if (e.type === 'activity:changed') cb(e.fetching);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
get syncHealth(): SyncHealth {
|
|
430
|
+
return this.runtime.state.sync.health;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
434
|
+
cb(this.syncHealth);
|
|
435
|
+
return this.runtime.on('health:changed', (e) => {
|
|
436
|
+
if (e.type === 'health:changed') cb(e.health);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
get storageHealth(): StorageHealth {
|
|
441
|
+
return this.services.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
445
|
+
if (this.services.local.subscribeToStorageHealth) return this.services.local.subscribeToStorageHealth(cb);
|
|
446
|
+
cb(UNKNOWN_STORAGE_HEALTH);
|
|
447
|
+
return () => {};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The engine state, read-only. For tests and DevTools. */
|
|
451
|
+
get state(): ClientState {
|
|
452
|
+
return this.runtime.state;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Feed an event into the engine. For adapters, tests and DevTools. */
|
|
456
|
+
dispatch(event: RuntimeEvent): Promise<void> {
|
|
457
|
+
return this.runtime.dispatch(event);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- auth / remote / crdt / flags / buckets ------------------------------------
|
|
461
|
+
|
|
462
|
+
authenticate(token: string) {
|
|
463
|
+
this.services.remote.setAuthToken(token);
|
|
464
|
+
return this.services.remote.getClient().authenticate(token);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
deauthenticate() {
|
|
468
|
+
return this.services.remote.getClient().invalidate();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async useRemote<T>(fn: (client: Surreal) => Promise<T> | T): Promise<T> {
|
|
472
|
+
this.assertRawRemoteAllowed('useRemote');
|
|
473
|
+
return fn(this.services.remote.getClient());
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async remoteQuery<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
|
|
477
|
+
this.assertRawRemoteAllowed('remoteQuery');
|
|
478
|
+
return this.services.remote.query<T>(sql, vars);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Tell the client that the app's own impersonation banner is on screen.
|
|
483
|
+
* Only meaningful with `impersonationBanner: { mode: 'custom' }`, where it
|
|
484
|
+
* cancels the fallback that would otherwise show the built-in banner. Call
|
|
485
|
+
* it while the custom banner is rendered (the Solid `useImpersonation`
|
|
486
|
+
* hook does it for you with `rendersBanner: true`).
|
|
487
|
+
*/
|
|
488
|
+
acknowledgeImpersonationBanner(): void {
|
|
489
|
+
this.impersonationBanner.acknowledge();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
feature(key: string, options?: FeatureFlagOptions): FeatureFlagHandle {
|
|
493
|
+
return this.featureFlags.feature(key, options);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
|
|
497
|
+
this.featureFlags.setLocalOverride(key, variant, payload);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
clearFeatureOverrides(): void {
|
|
501
|
+
this.featureFlags.clearLocalOverrides();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
getFeatureOverrides(): Record<string, FeatureFlagOverride> {
|
|
505
|
+
return this.featureFlags.getLocalOverrides();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
appRelease(app: string, options?: AppReleaseOptions): AppReleaseHandle {
|
|
509
|
+
return this.appReleases.release(app, options);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async openCrdtField(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField> {
|
|
513
|
+
return this.services.crdt.open(table, recordId, field, fallbackText);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
closeCrdtField(table: string, recordId: string, field: string): void {
|
|
517
|
+
this.services.crdt.close(table, recordId, field);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
521
|
+
return new BucketHandle(name, this.services.remote, this.services.blobs, { blurhash: this.config.blurhash, logger: this.services.logger });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getBlobCacheStats() {
|
|
525
|
+
return this.services.blobs.stats();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import type { EventDefinition } from './index';
|
|
3
|
+
import { EventSystem } from './index';
|
|
3
4
|
|
|
4
5
|
// Define test event types
|
|
5
6
|
type TestEvents = {
|
package/src/events/index.ts
CHANGED
|
@@ -172,6 +172,9 @@ export class EventSystem<E extends EventTypeMap> {
|
|
|
172
172
|
* @param payload The data associated with the event.
|
|
173
173
|
*/
|
|
174
174
|
emit<T extends EventType<E>, P extends EventPayload<E, T>>(type: T, payload: P): void {
|
|
175
|
+
// `{ type, payload }` structurally matches `Event<E, T>` (= the indexed
|
|
176
|
+
// access `E[T]`), but TS can't verify assignability to an indexed-access
|
|
177
|
+
// type over the generic `E`/`T`, so the constructed event is asserted.
|
|
175
178
|
const event = {
|
|
176
179
|
type,
|
|
177
180
|
payload,
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
export * from './types';
|
|
2
|
-
export * from './
|
|
2
|
+
export * from './sp00ky';
|
|
3
3
|
export * from './modules/auth/index';
|
|
4
|
-
export {
|
|
4
|
+
export { CrdtField, CrdtManager, cursorColorFromName, CURSOR_COLORS } from './modules/crdt/index';
|
|
5
|
+
export {
|
|
6
|
+
FeatureFlagModule,
|
|
7
|
+
FeatureFlagHandle,
|
|
8
|
+
type FeatureFlagOptions,
|
|
9
|
+
type FeatureFlagOverride,
|
|
10
|
+
type FeatureFlagSnapshot,
|
|
11
|
+
} from './modules/feature-flag/index';
|
|
12
|
+
export {
|
|
13
|
+
AppReleaseModule,
|
|
14
|
+
AppReleaseHandle,
|
|
15
|
+
type AppReleaseOptions,
|
|
16
|
+
type AppReleaseSnapshot,
|
|
17
|
+
} from './modules/app-release/index';
|
|
18
|
+
export type {
|
|
19
|
+
BlobCacheStats,
|
|
20
|
+
BlobEntry,
|
|
21
|
+
BlobKey,
|
|
22
|
+
BlobReadOptions,
|
|
23
|
+
BlobUrlLease,
|
|
24
|
+
} from './services/blobs/index';
|
|
25
|
+
export type { FailedMutationRow as FailedMutation } from './mutation/rows';
|
|
26
|
+
export { semverGt } from './utils/semver';
|
|
27
|
+
export { LocalOpTimeoutError, DEFAULT_LOCAL_OP_TIMEOUT_MS } from './services/database/errors';
|
|
28
|
+
export { fileToUint8Array, textToHtml } from './utils/index';
|
|
29
|
+
export {
|
|
30
|
+
blurhashSidecarPath,
|
|
31
|
+
encodeImageToBlurhash,
|
|
32
|
+
encodeBlurhash,
|
|
33
|
+
decodeBlurhash,
|
|
34
|
+
isBlurhashValid,
|
|
35
|
+
isImagePath,
|
|
36
|
+
BLURHASH_IMAGE_EXTENSIONS,
|
|
37
|
+
type BlurhashSetting,
|
|
38
|
+
type BlurhashEncodeOptions,
|
|
39
|
+
} from './utils/blurhash';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { backoffMs, RETRY_BASE_MS, RETRY_MAX_MS } from './constants';
|
|
3
|
+
|
|
4
|
+
describe('backoffMs', () => {
|
|
5
|
+
it('doubles from the base and caps at the max', () => {
|
|
6
|
+
expect(backoffMs(0)).toBe(RETRY_BASE_MS);
|
|
7
|
+
expect(backoffMs(1)).toBe(RETRY_BASE_MS * 2);
|
|
8
|
+
expect(backoffMs(3)).toBe(RETRY_BASE_MS * 8);
|
|
9
|
+
expect(backoffMs(20)).toBe(RETRY_MAX_MS);
|
|
10
|
+
});
|
|
11
|
+
it('clamps negative and huge attempts', () => {
|
|
12
|
+
expect(backoffMs(-5)).toBe(RETRY_BASE_MS);
|
|
13
|
+
expect(backoffMs(10_000)).toBe(RETRY_MAX_MS);
|
|
14
|
+
});
|
|
15
|
+
it('honours custom base and max', () => {
|
|
16
|
+
expect(backoffMs(2, 100, 250)).toBe(250);
|
|
17
|
+
expect(backoffMs(1, 100, 250)).toBe(200);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every timing constant of the saga core in one place. Sagas never call
|
|
3
|
+
* `setTimeout`; they yield `timer.set` effects with one of these delays, so a
|
|
4
|
+
* test can pin the schedule by asserting the effect log.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Trailing coalesce for materialization after a state change. */
|
|
8
|
+
export const MATERIALIZE_DEBOUNCE_MS = 50;
|
|
9
|
+
/** Coalesce window for LIVE membership dirt before the batched edge re-read. */
|
|
10
|
+
export const MEMBERSHIP_COALESCE_MS = 50;
|
|
11
|
+
/** Re-read ladder while the server reports a view as `materializing`. */
|
|
12
|
+
export const MATERIALIZING_REREAD_LADDER_MS: readonly number[] = [150, 400, 1000];
|
|
13
|
+
/** How long an acked outbox item stays in the overlay when membership never names it. */
|
|
14
|
+
export const ACK_GRACE_MS = 30_000;
|
|
15
|
+
/** Ids per body-fetch statement. */
|
|
16
|
+
export const FETCH_CHUNK = 500;
|
|
17
|
+
/** Outbox statements per push request. */
|
|
18
|
+
export const OUTBOX_BATCH_SIZE = 50;
|
|
19
|
+
/** Push deadline per outbox batch. */
|
|
20
|
+
export const PUSH_TIMEOUT_MS = 30_000;
|
|
21
|
+
/** Exponential backoff for outbox / fetch / registration retries. */
|
|
22
|
+
export const RETRY_BASE_MS = 500;
|
|
23
|
+
export const RETRY_MAX_MS = 15_000;
|
|
24
|
+
/** Registration attempts before a query is reported as failed to settle. */
|
|
25
|
+
export const REGISTER_MAX_RETRIES = 3;
|
|
26
|
+
/**
|
|
27
|
+
* Pacing of repeated view-lost recovery. The first re-registration after a
|
|
28
|
+
* `_00_query` row reads as gone is immediate; each further one for the same
|
|
29
|
+
* query, before the row has been seen again, waits on this backoff.
|
|
30
|
+
*/
|
|
31
|
+
export const VIEW_LOST_RETRY_BASE_MS = 1_000;
|
|
32
|
+
export const VIEW_LOST_RETRY_MAX_MS = 30_000;
|
|
33
|
+
/** `_00_list_ref` poll cadence (fallback when LIVE is quiet). */
|
|
34
|
+
export const LIST_REF_POLL_BASE_MS = 500;
|
|
35
|
+
export const LIST_REF_POLL_MAX_MS = 5_000;
|
|
36
|
+
export const LIST_REF_ROW_BUDGET = 1_500;
|
|
37
|
+
export const LIST_REF_LARGE_VIEW_EDGES = 1_000;
|
|
38
|
+
export const LIST_REF_LARGE_VIEW_POLL_MS = 15_000;
|
|
39
|
+
/** Self-heal cadence while sync health is degraded. */
|
|
40
|
+
export const SELF_HEAL_BASE_MS = 2_000;
|
|
41
|
+
export const SELF_HEAL_MAX_MS = 30_000;
|
|
42
|
+
/** Consecutive failed rounds before health flips to degraded. */
|
|
43
|
+
export const DEGRADE_AFTER_FAILURES = 3;
|
|
44
|
+
/** Burst coalescing of reconnects. */
|
|
45
|
+
export const RECONNECT_REFETCH_COOLDOWN_MS = 10_000;
|
|
46
|
+
/** Waiting for `$auth.id` to be visible after a reconnect. */
|
|
47
|
+
export const AUTH_READY_RETRY_MS = 500;
|
|
48
|
+
export const AUTH_READY_MAX_ATTEMPTS = 10;
|
|
49
|
+
/** Heartbeat at this fraction of the shortest ttl in state. */
|
|
50
|
+
export const TTL_HEARTBEAT_FRACTION = 0.5;
|
|
51
|
+
/** Orphan body garbage collection cadence. */
|
|
52
|
+
export const GC_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
53
|
+
/** Remote adapter slot limit. */
|
|
54
|
+
export const REMOTE_CONCURRENCY = 16;
|
|
55
|
+
/** Rolling telemetry sample window per query. */
|
|
56
|
+
export const TELEMETRY_SAMPLE_WINDOW = 100;
|
|
57
|
+
|
|
58
|
+
export function backoffMs(attempt: number, base = RETRY_BASE_MS, max = RETRY_MAX_MS): number {
|
|
59
|
+
const n = Math.max(0, Math.min(attempt, 30));
|
|
60
|
+
return Math.min(max, base * 2 ** n);
|
|
61
|
+
}
|