@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320
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 +15137 -2434
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +337 -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 +1811 -38
- package/dist/worker-edges.js +439 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +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 +51 -0
- package/src/client/services.ts +404 -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 +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +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 +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +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 +81 -0
- package/src/query/sql.ts +112 -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 +255 -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 +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +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,1013 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@spooky-sync/core/live`: a wasm-free live subscriber.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the exact server contract the full client uses, over the pure-JS
|
|
5
|
+
* `surrealdb` SDK and one WebSocket, with no local store, no circuit, no
|
|
6
|
+
* logger and no `window`: it runs in a service worker, a dedicated worker and
|
|
7
|
+
* Node.
|
|
8
|
+
*
|
|
9
|
+
* Per query:
|
|
10
|
+
* - `fn::query::register($config)` plus the membership read-back in one
|
|
11
|
+
* request (`registerSelect` / `registerVars`, the same builders the full
|
|
12
|
+
* client's `registerRemote` uses; `$config = { id: _00_query:<key>, surql,
|
|
13
|
+
* params, ttl }`, key = sha256(JSON.stringify({ surql, params, sessionId })));
|
|
14
|
+
* - bodies with `SELECT * FROM $ids`;
|
|
15
|
+
* - one `LIVE SELECT * FROM <the user's _00_list_ref table> FETCH out` per
|
|
16
|
+
* feed, so a changed row usually arrives inline with its edge; an edge whose
|
|
17
|
+
* `out` is a bare id (a server that ignores FETCH) costs a body read, one
|
|
18
|
+
* whose `out` is null (unreadable or deleted row) a membership re-read;
|
|
19
|
+
* - `fn::query::heartbeat` at half the ttl, re-registering a reclaimed view;
|
|
20
|
+
* - `fn::query::unsubscribe` for every view on `unsubscribe()` / `close()`.
|
|
21
|
+
*
|
|
22
|
+
* Rows are the SDK's decoded values (`id` is a `RecordId`), unordered: apply
|
|
23
|
+
* the query's order yourself if it matters. Related children (`.related()`)
|
|
24
|
+
* are not joined in; subscribe to them separately when you need them.
|
|
25
|
+
*/
|
|
26
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
27
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
28
|
+
import { queryHashInput } from '../query/hash';
|
|
29
|
+
import {
|
|
30
|
+
bodySelect,
|
|
31
|
+
heartbeatBatch,
|
|
32
|
+
heartbeatRowGone,
|
|
33
|
+
registerSelect,
|
|
34
|
+
registerVars,
|
|
35
|
+
singleSnapshotSelect,
|
|
36
|
+
type RegisterPayload,
|
|
37
|
+
} from '../query/sql';
|
|
38
|
+
import { metaFromRow, type QueryMetaRow } from '../query/membership';
|
|
39
|
+
import {
|
|
40
|
+
ANON_USER_ID,
|
|
41
|
+
DEFAULT_REF_MODE,
|
|
42
|
+
listRefTableFor,
|
|
43
|
+
type RefMode,
|
|
44
|
+
} from '../modules/ref-tables';
|
|
45
|
+
import { decodeTokenClaims } from '../modules/auth/impersonation';
|
|
46
|
+
import { hashOfEdge, rowOfEdge } from '../utils/edges';
|
|
47
|
+
import { sha256Hex } from '../utils/sha256';
|
|
48
|
+
import { parseQueryParams } from '../utils/parser';
|
|
49
|
+
import { encodeRecordId, parseDuration, parseRecordIdString, withTimeout } from '../utils/index';
|
|
50
|
+
|
|
51
|
+
export type LiveToken = string | null | undefined;
|
|
52
|
+
/** A token, or a function returning one (called on every connect). */
|
|
53
|
+
export type LiveTokenSource = string | (() => LiveToken | Promise<LiveToken>);
|
|
54
|
+
|
|
55
|
+
/** The subset of the `surrealdb` SDK's `Surreal` the feed drives. */
|
|
56
|
+
export interface LiveSurreal {
|
|
57
|
+
connect(url: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
58
|
+
use(what: { namespace: string; database: string }): Promise<unknown>;
|
|
59
|
+
authenticate(token: string): Promise<unknown>;
|
|
60
|
+
query(sql: string, vars?: Record<string, unknown>): PromiseLike<unknown>;
|
|
61
|
+
liveOf(id: never): PromiseLike<LiveStreamLike>;
|
|
62
|
+
close(): Promise<unknown>;
|
|
63
|
+
subscribe?(event: string, listener: (...args: unknown[]) => void): () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LiveStreamLike {
|
|
67
|
+
subscribe(handler: (message: LiveMessageLike) => void): () => void;
|
|
68
|
+
kill?(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LiveMessageLike {
|
|
72
|
+
action: string;
|
|
73
|
+
value: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface LiveFeedOptions {
|
|
77
|
+
/** SurrealDB endpoint, `wss://host/rpc` (http(s) is rewritten to ws(s)). */
|
|
78
|
+
endpoint: string;
|
|
79
|
+
namespace: string;
|
|
80
|
+
database: string;
|
|
81
|
+
/**
|
|
82
|
+
* The session token (`db.auth.token` on the page, `getBridgedToken()` in the
|
|
83
|
+
* service worker). None: the anonymous `_00_list_ref_anon` table, which
|
|
84
|
+
* needs `anonymousLiveQueries` on the server.
|
|
85
|
+
*/
|
|
86
|
+
token?: LiveTokenSource;
|
|
87
|
+
/** `_00_list_ref` layout. Default `dedicated` (one table per user), as the SSP. */
|
|
88
|
+
refMode?: RefMode;
|
|
89
|
+
/** View ttl sent with every registration. Default `10m`, the full client's. */
|
|
90
|
+
ttl?: string;
|
|
91
|
+
/** Salt of the `_00_query` keys. Default: a fresh random id per feed. */
|
|
92
|
+
sessionId?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The generated schema. Used to type query params (record ids, datetimes)
|
|
95
|
+
* exactly like the full client does before registering. Taken from a query
|
|
96
|
+
* builder's own schema when omitted.
|
|
97
|
+
*/
|
|
98
|
+
schema?: SchemaStructure;
|
|
99
|
+
/** The server refused the token. Called once, before the connect rejects. */
|
|
100
|
+
onUnauthorized?: (error: unknown) => void | Promise<void>;
|
|
101
|
+
/** Background failures (LIVE, heartbeat, re-reads). Never thrown. */
|
|
102
|
+
onError?: (error: unknown) => void;
|
|
103
|
+
/** Deadline for every statement, ms. Default 15000. */
|
|
104
|
+
timeoutMs?: number;
|
|
105
|
+
/** Bring your own SDK client (tests, a custom WebSocket implementation). */
|
|
106
|
+
surreal?: LiveSurreal | (() => LiveSurreal);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `{ surql, params }`, or anything the query builder builds (`FinalQuery`, `InnerQuery`). */
|
|
110
|
+
export type LiveQuery =
|
|
111
|
+
| { surql: string; params?: Record<string, unknown>; table?: string }
|
|
112
|
+
| BuiltQueryLike;
|
|
113
|
+
|
|
114
|
+
interface SelectInfoLike {
|
|
115
|
+
query: string;
|
|
116
|
+
vars?: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
interface InnerQueryLike {
|
|
119
|
+
selectQuery: SelectInfoLike;
|
|
120
|
+
tableName: string;
|
|
121
|
+
}
|
|
122
|
+
/** A `FinalQuery` (via its `innerQuery`) or an `InnerQuery`. */
|
|
123
|
+
type BuiltQueryLike = { innerQuery: InnerQueryLike } | InnerQueryLike;
|
|
124
|
+
|
|
125
|
+
export interface LiveChange<T> {
|
|
126
|
+
added: T[];
|
|
127
|
+
updated: T[];
|
|
128
|
+
/** The last known body of each row that left the set. */
|
|
129
|
+
removed: T[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface LiveHandlers<T> {
|
|
133
|
+
/** The whole current set: once when it first loads, then after every change. */
|
|
134
|
+
onSet(rows: T[]): void;
|
|
135
|
+
/** What changed, after the first set. */
|
|
136
|
+
onChange?(change: LiveChange<T>): void;
|
|
137
|
+
/** The registration or a later re-read failed. */
|
|
138
|
+
onError?(error: unknown): void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface LiveFeedSubscription<T> {
|
|
142
|
+
/** The first full set; rejects when the registration failed. */
|
|
143
|
+
readonly ready: Promise<T[]>;
|
|
144
|
+
/** The current rows, unordered. Empty until `ready`. */
|
|
145
|
+
rows(): T[];
|
|
146
|
+
/** The view's `_00_query` key, once computed. */
|
|
147
|
+
readonly key: string | null;
|
|
148
|
+
/** Stop listening; releases the server view when nobody else on this feed watches it. */
|
|
149
|
+
unsubscribe(): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface LiveIdleOptions {
|
|
153
|
+
/** Give up after this long and resolve `false`. Default 10000. */
|
|
154
|
+
timeoutMs?: number;
|
|
155
|
+
/** How long nothing must have arrived. Default 300. */
|
|
156
|
+
quietMs?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface LiveFeed {
|
|
160
|
+
subscribe<T = Record<string, unknown>>(
|
|
161
|
+
query: LiveQuery,
|
|
162
|
+
handlers: LiveHandlers<T>
|
|
163
|
+
): LiveFeedSubscription<T>;
|
|
164
|
+
/** Run statements on the feed's connection (small writes, `fn::push::*`). The SDK's result array. */
|
|
165
|
+
query<T extends unknown[] = unknown[]>(surql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
166
|
+
/** `true` once every subscription has its first set and nothing arrived for `quietMs`. */
|
|
167
|
+
idle(options?: LiveIdleOptions): Promise<boolean>;
|
|
168
|
+
/** Connect now (subscribe/query do it lazily). */
|
|
169
|
+
connect(): Promise<void>;
|
|
170
|
+
/** Release every view, kill LIVE, close the socket. Idempotent. */
|
|
171
|
+
close(): Promise<void>;
|
|
172
|
+
/** `$auth.id` from the token (`user:abc`), null when anonymous. Known after connect. */
|
|
173
|
+
readonly userId: string | null;
|
|
174
|
+
/** The `_00_list_ref` table LIVE watches. Known after connect. */
|
|
175
|
+
readonly listRefTable: string | null;
|
|
176
|
+
readonly closed: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type LiveFeedErrorCode = 'closed' | 'unauthorized' | 'connect' | 'register' | 'query';
|
|
180
|
+
|
|
181
|
+
export class LiveFeedError extends Error {
|
|
182
|
+
constructor(
|
|
183
|
+
public readonly code: LiveFeedErrorCode,
|
|
184
|
+
message: string,
|
|
185
|
+
public readonly cause?: unknown
|
|
186
|
+
) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.name = 'LiveFeedError';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createLiveFeed(options: LiveFeedOptions): LiveFeed {
|
|
193
|
+
return new Feed(options);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const MATERIALIZE_RETRIES = 20;
|
|
199
|
+
const MATERIALIZE_RETRY_MS = 150;
|
|
200
|
+
const FLUSH_MS = 10;
|
|
201
|
+
const FETCH_BATCH_MS = 15;
|
|
202
|
+
const REREAD_MS = 50;
|
|
203
|
+
const RELEASE_TIMEOUT_MS = 2_000;
|
|
204
|
+
|
|
205
|
+
interface Edge {
|
|
206
|
+
rid: RecordId;
|
|
207
|
+
version: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface Held {
|
|
211
|
+
version: number;
|
|
212
|
+
row: Record<string, unknown>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface Listener {
|
|
216
|
+
handlers: LiveHandlers<any>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface Deferred<T> {
|
|
220
|
+
promise: Promise<T>;
|
|
221
|
+
resolve(value: T): void;
|
|
222
|
+
reject(error: unknown): void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function deferred<T>(): Deferred<T> {
|
|
226
|
+
let resolve!: (value: T) => void;
|
|
227
|
+
let reject!: (error: unknown) => void;
|
|
228
|
+
const promise = new Promise<T>((res, rej) => {
|
|
229
|
+
resolve = res;
|
|
230
|
+
reject = rej;
|
|
231
|
+
});
|
|
232
|
+
// A subscription nobody awaits must not surface as an unhandled rejection.
|
|
233
|
+
promise.catch(() => undefined);
|
|
234
|
+
return { promise, resolve, reject };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
class SubState {
|
|
238
|
+
readonly rid: RecordId<string>;
|
|
239
|
+
readonly payload: RegisterPayload;
|
|
240
|
+
readonly rows = new Map<string, Held>();
|
|
241
|
+
readonly listeners = new Set<Listener>();
|
|
242
|
+
readonly ready = deferred<Record<string, unknown>[]>();
|
|
243
|
+
loaded = false;
|
|
244
|
+
failed = false;
|
|
245
|
+
registered = false;
|
|
246
|
+
/** A LIVE event hit this view before its first set landed. */
|
|
247
|
+
dirtyDuringLoad = false;
|
|
248
|
+
pending: {
|
|
249
|
+
added: Map<string, Record<string, unknown>>;
|
|
250
|
+
updated: Map<string, Record<string, unknown>>;
|
|
251
|
+
removed: Map<string, Record<string, unknown>>;
|
|
252
|
+
} = {
|
|
253
|
+
added: new Map(),
|
|
254
|
+
updated: new Map(),
|
|
255
|
+
removed: new Map(),
|
|
256
|
+
};
|
|
257
|
+
flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
258
|
+
rereadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
259
|
+
|
|
260
|
+
constructor(
|
|
261
|
+
readonly key: string,
|
|
262
|
+
surql: string,
|
|
263
|
+
params: Record<string, unknown>,
|
|
264
|
+
ttl: string
|
|
265
|
+
) {
|
|
266
|
+
this.rid = new RecordId('_00_query', key);
|
|
267
|
+
this.payload = { id: this.rid, surql, params, ttl };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
list(): Record<string, unknown>[] {
|
|
271
|
+
return [...this.rows.values()].map((h) => h.row);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isBuilt(query: LiveQuery): query is BuiltQueryLike {
|
|
276
|
+
const q = query as { innerQuery?: InnerQueryLike; selectQuery?: SelectInfoLike } | null;
|
|
277
|
+
return !!(q && (q.innerQuery?.selectQuery || q.selectQuery));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The register input the full client derives from the same query. */
|
|
281
|
+
export function liveQueryInput(
|
|
282
|
+
query: LiveQuery,
|
|
283
|
+
schema?: SchemaStructure
|
|
284
|
+
): { table: string; surql: string; params: Record<string, unknown> } {
|
|
285
|
+
if (isBuilt(query)) {
|
|
286
|
+
const inner = ('innerQuery' in query ? query.innerQuery : query) as InnerQueryLike & {
|
|
287
|
+
schema?: SchemaStructure;
|
|
288
|
+
};
|
|
289
|
+
const info = inner.selectQuery;
|
|
290
|
+
const table = inner.tableName;
|
|
291
|
+
// The builders keep their schema in a private field; reading it is how a
|
|
292
|
+
// bare `FinalQuery` still gets the full client's param typing.
|
|
293
|
+
const s = schema ?? inner.schema ?? (query as { schema?: SchemaStructure }).schema;
|
|
294
|
+
const columns = s?.tables?.find((t) => t.name === table)?.columns;
|
|
295
|
+
const vars = info.vars ?? {};
|
|
296
|
+
return {
|
|
297
|
+
table,
|
|
298
|
+
surql: info.query,
|
|
299
|
+
params: columns ? parseQueryParams(columns as never, vars) : { ...vars },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const raw = query as { surql?: unknown; params?: Record<string, unknown>; table?: string };
|
|
303
|
+
if (typeof raw?.surql !== 'string')
|
|
304
|
+
throw new TypeError('live feed: expected a built query or { surql, params }');
|
|
305
|
+
const table = raw.table ?? /\bFROM\s+([A-Za-z0-9_]+)/i.exec(raw.surql)?.[1] ?? '';
|
|
306
|
+
return { table, surql: raw.surql, params: { ...raw.params } };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** `http(s)://` becomes `ws(s)://`; a bare host gets `/rpc`. */
|
|
310
|
+
export function liveEndpoint(endpoint: string): string {
|
|
311
|
+
try {
|
|
312
|
+
const url = new URL(endpoint);
|
|
313
|
+
if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
314
|
+
else if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
315
|
+
if (url.pathname === '' || url.pathname === '/') url.pathname = '/rpc';
|
|
316
|
+
return url.toString();
|
|
317
|
+
} catch {
|
|
318
|
+
return endpoint;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function asRecordId(value: unknown): RecordId | null {
|
|
323
|
+
if (value instanceof RecordId) return value;
|
|
324
|
+
if (typeof value === 'string' && value.includes(':')) return parseRecordIdString(value);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function keyOf(rid: RecordId): string {
|
|
329
|
+
return encodeRecordId(rid as RecordId<string>);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `SELECT out, version ...` rows into edges, one per id at its highest version. */
|
|
333
|
+
function parseEdges(result: unknown): Map<string, Edge> | null {
|
|
334
|
+
if (!Array.isArray(result)) return null;
|
|
335
|
+
const out = new Map<string, Edge>();
|
|
336
|
+
for (const row of result as Array<{ out?: unknown; version?: unknown }>) {
|
|
337
|
+
const rid = asRecordId(row?.out);
|
|
338
|
+
if (!rid) continue;
|
|
339
|
+
const version = typeof row.version === 'number' ? row.version : 0;
|
|
340
|
+
const key = keyOf(rid);
|
|
341
|
+
const held = out.get(key);
|
|
342
|
+
if (!held || held.version < version) out.set(key, { rid, version });
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
interface Snapshot {
|
|
348
|
+
edges: Map<string, Edge>;
|
|
349
|
+
state: string | null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The three membership statements starting at `offset`: edges, meta, children. */
|
|
353
|
+
function snapshotAt(results: unknown, offset: number): Snapshot | null {
|
|
354
|
+
if (!Array.isArray(results)) return null;
|
|
355
|
+
const edges = parseEdges(results[offset]);
|
|
356
|
+
if (!edges) return null;
|
|
357
|
+
const meta = metaFromRow(results[offset + 1] as QueryMetaRow | null);
|
|
358
|
+
return { edges, state: meta.state };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
362
|
+
|
|
363
|
+
function randomId(): string {
|
|
364
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
365
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
366
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function resolveToken(source: LiveTokenSource | undefined): Promise<string | null> {
|
|
370
|
+
if (!source) return null;
|
|
371
|
+
const value = typeof source === 'function' ? await source() : source;
|
|
372
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
class Feed implements LiveFeed {
|
|
376
|
+
private db: LiveSurreal | null = null;
|
|
377
|
+
private connecting: Promise<void> | null = null;
|
|
378
|
+
private uid: string | null = null;
|
|
379
|
+
private table: string | null = null;
|
|
380
|
+
private live: { id: unknown; stream: LiveStreamLike; off: () => void } | null = null;
|
|
381
|
+
private liveStarting: Promise<boolean> | null = null;
|
|
382
|
+
private readonly subs = new Map<string, SubState>();
|
|
383
|
+
private readonly sessionId: string;
|
|
384
|
+
private readonly ttl: string;
|
|
385
|
+
private readonly timeoutMs: number;
|
|
386
|
+
private isClosed = false;
|
|
387
|
+
private lastActivity = Date.now();
|
|
388
|
+
private busy = 0;
|
|
389
|
+
private starting = 0;
|
|
390
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
391
|
+
private fetchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
392
|
+
private readonly fetchQueue = new Map<
|
|
393
|
+
string,
|
|
394
|
+
{ rid: RecordId; waiters: Array<{ sub: SubState; version: number }> }
|
|
395
|
+
>();
|
|
396
|
+
private readonly offs: Array<() => void> = [];
|
|
397
|
+
private dropped = false;
|
|
398
|
+
|
|
399
|
+
constructor(private readonly options: LiveFeedOptions) {
|
|
400
|
+
this.sessionId = options.sessionId ?? randomId();
|
|
401
|
+
this.ttl = options.ttl ?? '10m';
|
|
402
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
get userId(): string | null {
|
|
406
|
+
return this.uid;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
get listRefTable(): string | null {
|
|
410
|
+
return this.table;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
get closed(): boolean {
|
|
414
|
+
return this.isClosed;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── connection ──
|
|
418
|
+
|
|
419
|
+
connect(): Promise<void> {
|
|
420
|
+
if (this.isClosed) return Promise.reject(new LiveFeedError('closed', 'live feed is closed'));
|
|
421
|
+
if (this.db) return Promise.resolve();
|
|
422
|
+
if (!this.connecting) {
|
|
423
|
+
this.connecting = this.open().catch((error) => {
|
|
424
|
+
this.connecting = null;
|
|
425
|
+
throw error;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return this.connecting;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private makeSurreal(): LiveSurreal {
|
|
432
|
+
const given = this.options.surreal;
|
|
433
|
+
if (typeof given === 'function') return given();
|
|
434
|
+
if (given) return given;
|
|
435
|
+
return new Surreal() as unknown as LiveSurreal;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private async open(): Promise<void> {
|
|
439
|
+
const token = await resolveToken(this.options.token);
|
|
440
|
+
this.uid = token ? decodeTokenClaims(token).userId : null;
|
|
441
|
+
this.table = listRefTableFor(
|
|
442
|
+
this.options.refMode ?? DEFAULT_REF_MODE,
|
|
443
|
+
this.uid ?? ANON_USER_ID
|
|
444
|
+
);
|
|
445
|
+
const db = this.makeSurreal();
|
|
446
|
+
try {
|
|
447
|
+
await db.connect(liveEndpoint(this.options.endpoint), {
|
|
448
|
+
reconnect: {
|
|
449
|
+
enabled: true,
|
|
450
|
+
attempts: 5,
|
|
451
|
+
retryDelay: 500,
|
|
452
|
+
retryDelayMax: 5_000,
|
|
453
|
+
retryDelayMultiplier: 2,
|
|
454
|
+
retryDelayJitter: 0.1,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
await db.use({ namespace: this.options.namespace, database: this.options.database });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
await db.close().catch(() => undefined);
|
|
460
|
+
throw new LiveFeedError(
|
|
461
|
+
'connect',
|
|
462
|
+
`live feed: could not connect to ${this.options.endpoint}`,
|
|
463
|
+
error
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (token) {
|
|
467
|
+
try {
|
|
468
|
+
await db.authenticate(token);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
await db.close().catch(() => undefined);
|
|
471
|
+
try {
|
|
472
|
+
await this.options.onUnauthorized?.(error);
|
|
473
|
+
} catch {
|
|
474
|
+
/* the app's hook must not mask the refusal */
|
|
475
|
+
}
|
|
476
|
+
throw new LiveFeedError('unauthorized', 'live feed: the server refused the token', error);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (this.isClosed) {
|
|
480
|
+
await db.close().catch(() => undefined);
|
|
481
|
+
throw new LiveFeedError('closed', 'live feed is closed');
|
|
482
|
+
}
|
|
483
|
+
if (db.subscribe) {
|
|
484
|
+
const drop = () => {
|
|
485
|
+
// The server-side LIVE query died with the socket.
|
|
486
|
+
this.dropped = true;
|
|
487
|
+
this.live?.off();
|
|
488
|
+
this.live = null;
|
|
489
|
+
};
|
|
490
|
+
this.offs.push(
|
|
491
|
+
db.subscribe('disconnected', drop),
|
|
492
|
+
db.subscribe('reconnecting', drop),
|
|
493
|
+
db.subscribe('connected', () => {
|
|
494
|
+
if (!this.dropped || this.isClosed) return;
|
|
495
|
+
this.dropped = false;
|
|
496
|
+
void this.recover();
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
this.db = db;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** After a reconnect: LIVE again, then re-read every view (events in the gap are lost). */
|
|
504
|
+
private async recover(): Promise<void> {
|
|
505
|
+
try {
|
|
506
|
+
await this.ensureLive();
|
|
507
|
+
for (const sub of this.subs.values()) if (sub.loaded) this.scheduleReread(sub);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
this.report(error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private async q(sql: string, vars?: Record<string, unknown>): Promise<unknown[]> {
|
|
514
|
+
await this.connect();
|
|
515
|
+
const db = this.db!;
|
|
516
|
+
const result = await withTimeout(
|
|
517
|
+
Promise.resolve(db.query(sql, vars)),
|
|
518
|
+
this.timeoutMs,
|
|
519
|
+
'live feed statement timed out'
|
|
520
|
+
);
|
|
521
|
+
return Array.isArray(result) ? result : [result];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async query<T extends unknown[] = unknown[]>(
|
|
525
|
+
surql: string,
|
|
526
|
+
vars?: Record<string, unknown>
|
|
527
|
+
): Promise<T> {
|
|
528
|
+
try {
|
|
529
|
+
return (await this.q(surql, vars)) as T;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error instanceof LiveFeedError) throw error;
|
|
532
|
+
throw new LiveFeedError(
|
|
533
|
+
'query',
|
|
534
|
+
error instanceof Error ? error.message : String(error),
|
|
535
|
+
error
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private report(error: unknown): void {
|
|
541
|
+
try {
|
|
542
|
+
this.options.onError?.(error);
|
|
543
|
+
} catch {
|
|
544
|
+
/* never let a reporter break the feed */
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private touch(): void {
|
|
549
|
+
this.lastActivity = Date.now();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── LIVE ──
|
|
553
|
+
|
|
554
|
+
/** Start the feed's one LIVE query. `true` when this call started it. */
|
|
555
|
+
private async ensureLive(): Promise<boolean> {
|
|
556
|
+
if (this.live || this.isClosed) return false;
|
|
557
|
+
if (!this.liveStarting) {
|
|
558
|
+
this.liveStarting = (async () => {
|
|
559
|
+
try {
|
|
560
|
+
let id: unknown;
|
|
561
|
+
try {
|
|
562
|
+
[id] = await this.q(`LIVE SELECT * FROM ${this.table} FETCH out`);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
// A server that refuses FETCH on LIVE still has the doorbell:
|
|
565
|
+
// bare-id edges fall back to a body read in `onLive`.
|
|
566
|
+
this.report(error);
|
|
567
|
+
[id] = await this.q(`LIVE SELECT * FROM ${this.table}`);
|
|
568
|
+
}
|
|
569
|
+
const stream = await this.db!.liveOf(id as never);
|
|
570
|
+
const off = stream.subscribe((message) => this.onLive(message));
|
|
571
|
+
if (this.isClosed) {
|
|
572
|
+
off();
|
|
573
|
+
await (stream.kill?.() ?? Promise.resolve()).catch(() => undefined);
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
this.live = { id, stream, off };
|
|
577
|
+
return true;
|
|
578
|
+
} catch (error) {
|
|
579
|
+
// Membership still loads; only change delivery is missing.
|
|
580
|
+
this.report(error);
|
|
581
|
+
return false;
|
|
582
|
+
} finally {
|
|
583
|
+
this.liveStarting = null;
|
|
584
|
+
}
|
|
585
|
+
})();
|
|
586
|
+
}
|
|
587
|
+
return this.liveStarting;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private onLive(message: LiveMessageLike): void {
|
|
591
|
+
if (message.action === 'KILLED' || this.isClosed) return;
|
|
592
|
+
const edge = message.value as { out?: unknown; version?: unknown; parent?: unknown } | null;
|
|
593
|
+
const hash = hashOfEdge(edge);
|
|
594
|
+
const sub = hash ? this.subs.get(hash) : undefined;
|
|
595
|
+
if (!sub || !edge) return;
|
|
596
|
+
// Subquery children (`.related()`) are not part of the set this feed serves.
|
|
597
|
+
if (edge.parent !== undefined && edge.parent !== null) return;
|
|
598
|
+
this.touch();
|
|
599
|
+
if (!sub.loaded) {
|
|
600
|
+
sub.dirtyDuringLoad = true;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const out = edge.out;
|
|
604
|
+
if (message.action === 'DELETE') {
|
|
605
|
+
const rid = asRecordId(out) ?? asRecordId((out as { id?: unknown } | null)?.id);
|
|
606
|
+
if (rid) this.remove(sub, keyOf(rid));
|
|
607
|
+
else this.scheduleReread(sub);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const inline = rowOfEdge(edge);
|
|
611
|
+
if (inline) {
|
|
612
|
+
this.upsert(sub, inline.id, inline.version, inline.record);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const rid = asRecordId(out);
|
|
616
|
+
if (rid) {
|
|
617
|
+
this.enqueueFetch(sub, rid, typeof edge.version === 'number' ? edge.version : 0);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
// `out: null`: a row this session may not read, or one that is gone.
|
|
621
|
+
this.scheduleReread(sub);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ── set bookkeeping ──
|
|
625
|
+
|
|
626
|
+
private upsert(sub: SubState, key: string, version: number, row: Record<string, unknown>): void {
|
|
627
|
+
const held = sub.rows.get(key);
|
|
628
|
+
if (held && held.version >= version) return;
|
|
629
|
+
sub.rows.set(key, { version, row });
|
|
630
|
+
if (sub.pending.removed.has(key)) {
|
|
631
|
+
sub.pending.removed.delete(key);
|
|
632
|
+
sub.pending.updated.set(key, row);
|
|
633
|
+
} else if (held || sub.pending.updated.has(key)) {
|
|
634
|
+
if (sub.pending.added.has(key)) sub.pending.added.set(key, row);
|
|
635
|
+
else sub.pending.updated.set(key, row);
|
|
636
|
+
} else {
|
|
637
|
+
sub.pending.added.set(key, row);
|
|
638
|
+
}
|
|
639
|
+
this.scheduleFlush(sub);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private remove(sub: SubState, key: string): void {
|
|
643
|
+
const held = sub.rows.get(key);
|
|
644
|
+
if (!held) return;
|
|
645
|
+
sub.rows.delete(key);
|
|
646
|
+
if (sub.pending.added.has(key)) sub.pending.added.delete(key);
|
|
647
|
+
else {
|
|
648
|
+
sub.pending.updated.delete(key);
|
|
649
|
+
sub.pending.removed.set(key, held.row);
|
|
650
|
+
}
|
|
651
|
+
this.scheduleFlush(sub);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private scheduleFlush(sub: SubState): void {
|
|
655
|
+
this.touch();
|
|
656
|
+
if (sub.flushTimer) return;
|
|
657
|
+
sub.flushTimer = setTimeout(() => {
|
|
658
|
+
sub.flushTimer = null;
|
|
659
|
+
this.flush(sub);
|
|
660
|
+
}, FLUSH_MS);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private flush(sub: SubState): void {
|
|
664
|
+
const { added, updated, removed } = sub.pending;
|
|
665
|
+
if (added.size + updated.size + removed.size === 0) return;
|
|
666
|
+
sub.pending = { added: new Map(), updated: new Map(), removed: new Map() };
|
|
667
|
+
const change = {
|
|
668
|
+
added: [...added.values()],
|
|
669
|
+
updated: [...updated.values()],
|
|
670
|
+
removed: [...removed.values()],
|
|
671
|
+
};
|
|
672
|
+
const rows = sub.list();
|
|
673
|
+
this.touch();
|
|
674
|
+
for (const l of [...sub.listeners]) {
|
|
675
|
+
try {
|
|
676
|
+
l.handlers.onChange?.(change);
|
|
677
|
+
l.handlers.onSet(rows);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
this.report(error);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── bodies ──
|
|
685
|
+
|
|
686
|
+
private enqueueFetch(sub: SubState, rid: RecordId, version: number): void {
|
|
687
|
+
const key = keyOf(rid);
|
|
688
|
+
const entry = this.fetchQueue.get(key) ?? { rid, waiters: [] };
|
|
689
|
+
entry.waiters.push({ sub, version });
|
|
690
|
+
this.fetchQueue.set(key, entry);
|
|
691
|
+
if (this.fetchTimer) return;
|
|
692
|
+
this.busy++;
|
|
693
|
+
this.fetchTimer = setTimeout(() => {
|
|
694
|
+
this.fetchTimer = null;
|
|
695
|
+
void this.drainFetches().finally(() => {
|
|
696
|
+
this.busy--;
|
|
697
|
+
this.touch();
|
|
698
|
+
});
|
|
699
|
+
}, FETCH_BATCH_MS);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async drainFetches(): Promise<void> {
|
|
703
|
+
const batch = [...this.fetchQueue.values()];
|
|
704
|
+
this.fetchQueue.clear();
|
|
705
|
+
if (batch.length === 0 || this.isClosed) return;
|
|
706
|
+
try {
|
|
707
|
+
const bodies = await this.fetchBodies(batch.map((e) => e.rid));
|
|
708
|
+
for (const entry of batch) {
|
|
709
|
+
const key = keyOf(entry.rid);
|
|
710
|
+
const body = bodies.get(key);
|
|
711
|
+
for (const w of entry.waiters) {
|
|
712
|
+
if (!this.subs.has(w.sub.key)) continue;
|
|
713
|
+
if (body) this.upsert(w.sub, key, w.version, body);
|
|
714
|
+
else this.scheduleReread(w.sub);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
} catch (error) {
|
|
718
|
+
this.report(error);
|
|
719
|
+
for (const entry of batch) for (const w of entry.waiters) this.scheduleReread(w.sub);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private async fetchBodies(rids: RecordId[]): Promise<Map<string, Record<string, unknown>>> {
|
|
724
|
+
const out = new Map<string, Record<string, unknown>>();
|
|
725
|
+
if (rids.length === 0) return out;
|
|
726
|
+
const [rows] = await this.q(bodySelect(), { ids: rids });
|
|
727
|
+
for (const row of Array.isArray(rows) ? (rows as Record<string, unknown>[]) : []) {
|
|
728
|
+
const rid = asRecordId(row?.id);
|
|
729
|
+
if (rid) out.set(keyOf(rid), row);
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── membership ──
|
|
735
|
+
|
|
736
|
+
private async readSnapshot(sub: SubState): Promise<Snapshot | null> {
|
|
737
|
+
const results = await this.q(singleSnapshotSelect(this.table!), { in: sub.rid });
|
|
738
|
+
return snapshotAt(results, 0);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Make `sub.rows` equal the snapshot: fetch what is new or newer, drop what left. */
|
|
742
|
+
private async applySnapshot(sub: SubState, snap: Snapshot): Promise<void> {
|
|
743
|
+
const need = [...snap.edges.entries()].filter(
|
|
744
|
+
([key, e]) => (sub.rows.get(key)?.version ?? -1) < e.version
|
|
745
|
+
);
|
|
746
|
+
const bodies = await this.fetchBodies(need.map(([, e]) => e.rid));
|
|
747
|
+
if (!this.subs.has(sub.key)) return;
|
|
748
|
+
for (const [key, e] of need) {
|
|
749
|
+
const body = bodies.get(key);
|
|
750
|
+
if (body) this.upsert(sub, key, e.version, body);
|
|
751
|
+
}
|
|
752
|
+
for (const key of [...sub.rows.keys()]) if (!snap.edges.has(key)) this.remove(sub, key);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private scheduleReread(sub: SubState): void {
|
|
756
|
+
if (sub.rereadTimer || this.isClosed) return;
|
|
757
|
+
this.busy++;
|
|
758
|
+
sub.rereadTimer = setTimeout(() => {
|
|
759
|
+
sub.rereadTimer = null;
|
|
760
|
+
void this.reread(sub).finally(() => {
|
|
761
|
+
this.busy--;
|
|
762
|
+
this.touch();
|
|
763
|
+
});
|
|
764
|
+
}, REREAD_MS);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private async reread(sub: SubState): Promise<void> {
|
|
768
|
+
if (!this.subs.has(sub.key) || this.isClosed) return;
|
|
769
|
+
try {
|
|
770
|
+
const snap = await this.readSnapshot(sub);
|
|
771
|
+
if (!snap) throw new LiveFeedError('query', 'membership re-read did not answer');
|
|
772
|
+
await this.applySnapshot(sub, snap);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
this.report(error);
|
|
775
|
+
for (const l of sub.listeners) l.handlers.onError?.(error);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Register (or re-register) a view and read its membership back, in one request. */
|
|
780
|
+
private async register(sub: SubState): Promise<Snapshot> {
|
|
781
|
+
const results = await this.q(registerSelect(this.table!), registerVars(sub.payload));
|
|
782
|
+
let snap = snapshotAt(results, 1);
|
|
783
|
+
for (let i = 0; snap && snap.state === 'materializing' && i < MATERIALIZE_RETRIES; i++) {
|
|
784
|
+
await sleep(MATERIALIZE_RETRY_MS);
|
|
785
|
+
snap = await this.readSnapshot(sub);
|
|
786
|
+
}
|
|
787
|
+
if (!snap) throw new LiveFeedError('register', 'registration read-back did not answer');
|
|
788
|
+
sub.registered = true;
|
|
789
|
+
this.ensureHeartbeat();
|
|
790
|
+
return snap;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private async load(sub: SubState): Promise<void> {
|
|
794
|
+
this.busy++;
|
|
795
|
+
try {
|
|
796
|
+
await this.connect();
|
|
797
|
+
let snap = await this.register(sub);
|
|
798
|
+
// LIVE starts after the first registration (the SSP creates a per-user
|
|
799
|
+
// table lazily), so whatever changed between that read-back and LIVE
|
|
800
|
+
// coming up is only in a fresh read.
|
|
801
|
+
if (await this.ensureLive()) snap = (await this.readSnapshot(sub)) ?? snap;
|
|
802
|
+
if (!this.subs.has(sub.key)) return;
|
|
803
|
+
const bodies = await this.fetchBodies([...snap.edges.values()].map((e) => e.rid));
|
|
804
|
+
for (const [key, e] of snap.edges) {
|
|
805
|
+
const body = bodies.get(key);
|
|
806
|
+
if (body) sub.rows.set(key, { version: e.version, row: body });
|
|
807
|
+
}
|
|
808
|
+
sub.loaded = true;
|
|
809
|
+
const rows = sub.list();
|
|
810
|
+
sub.ready.resolve(rows);
|
|
811
|
+
this.touch();
|
|
812
|
+
for (const l of [...sub.listeners]) this.deliverInitial(l, rows);
|
|
813
|
+
if (sub.dirtyDuringLoad) {
|
|
814
|
+
sub.dirtyDuringLoad = false;
|
|
815
|
+
this.scheduleReread(sub);
|
|
816
|
+
}
|
|
817
|
+
} catch (error) {
|
|
818
|
+
sub.failed = true;
|
|
819
|
+
const err =
|
|
820
|
+
error instanceof LiveFeedError
|
|
821
|
+
? error
|
|
822
|
+
: new LiveFeedError(
|
|
823
|
+
'register',
|
|
824
|
+
error instanceof Error ? error.message : String(error),
|
|
825
|
+
error
|
|
826
|
+
);
|
|
827
|
+
sub.ready.reject(err);
|
|
828
|
+
for (const l of [...sub.listeners]) l.handlers.onError?.(err);
|
|
829
|
+
this.subs.delete(sub.key);
|
|
830
|
+
} finally {
|
|
831
|
+
this.busy--;
|
|
832
|
+
this.touch();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private deliverInitial(listener: Listener, rows: Record<string, unknown>[]): void {
|
|
837
|
+
try {
|
|
838
|
+
listener.handlers.onSet(rows);
|
|
839
|
+
} catch (error) {
|
|
840
|
+
this.report(error);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── heartbeat ──
|
|
845
|
+
|
|
846
|
+
private ensureHeartbeat(): void {
|
|
847
|
+
if (this.heartbeatTimer || this.isClosed) return;
|
|
848
|
+
const every = Math.max(5_000, Math.floor(parseDuration(this.ttl as never) / 2));
|
|
849
|
+
this.heartbeatTimer = setInterval(() => void this.heartbeat(), every);
|
|
850
|
+
(this.heartbeatTimer as { unref?: () => void }).unref?.();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private async heartbeat(): Promise<void> {
|
|
854
|
+
const subs = [...this.subs.values()].filter((s) => s.registered);
|
|
855
|
+
if (subs.length === 0 || this.isClosed) return;
|
|
856
|
+
try {
|
|
857
|
+
const beat = heartbeatBatch(subs.map((s) => s.rid));
|
|
858
|
+
const results = await this.q(beat.sql, beat.vars);
|
|
859
|
+
for (let i = 0; i < subs.length; i++) {
|
|
860
|
+
if (!heartbeatRowGone(results[i])) continue;
|
|
861
|
+
// The server reclaimed the view: register it again and diff.
|
|
862
|
+
const sub = subs[i];
|
|
863
|
+
try {
|
|
864
|
+
const snap = await this.register(sub);
|
|
865
|
+
await this.applySnapshot(sub, snap);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
this.report(error);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch (error) {
|
|
871
|
+
this.report(error);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ── public ──
|
|
876
|
+
|
|
877
|
+
subscribe<T = Record<string, unknown>>(
|
|
878
|
+
query: LiveQuery,
|
|
879
|
+
handlers: LiveHandlers<T>
|
|
880
|
+
): LiveFeedSubscription<T> {
|
|
881
|
+
const listener: Listener = { handlers };
|
|
882
|
+
let key: string | null = null;
|
|
883
|
+
let state: SubState | null = null;
|
|
884
|
+
let gone = false;
|
|
885
|
+
this.starting++;
|
|
886
|
+
const attached: Promise<SubState> = (async () => {
|
|
887
|
+
try {
|
|
888
|
+
if (this.isClosed) throw new LiveFeedError('closed', 'live feed is closed');
|
|
889
|
+
const input = liveQueryInput(query, this.options.schema);
|
|
890
|
+
key = await sha256Hex(
|
|
891
|
+
queryHashInput({ surql: input.surql, params: input.params }, this.sessionId)
|
|
892
|
+
);
|
|
893
|
+
if (gone) throw new LiveFeedError('closed', 'unsubscribed before the view was registered');
|
|
894
|
+
let sub = this.subs.get(key);
|
|
895
|
+
const fresh = !sub;
|
|
896
|
+
if (!sub) {
|
|
897
|
+
sub = new SubState(key, input.surql, input.params, this.ttl);
|
|
898
|
+
this.subs.set(key, sub);
|
|
899
|
+
}
|
|
900
|
+
state = sub;
|
|
901
|
+
sub.listeners.add(listener);
|
|
902
|
+
if (fresh) void this.load(sub);
|
|
903
|
+
else if (sub.loaded) this.deliverInitial(listener, sub.list());
|
|
904
|
+
return sub;
|
|
905
|
+
} finally {
|
|
906
|
+
this.starting--;
|
|
907
|
+
}
|
|
908
|
+
})();
|
|
909
|
+
const ready = attached.then((sub) => sub.ready.promise) as Promise<T[]>;
|
|
910
|
+
ready.catch((error) => {
|
|
911
|
+
// Errors before a state existed (bad query, closed feed) still reach onError.
|
|
912
|
+
if (!state) handlers.onError?.(error);
|
|
913
|
+
});
|
|
914
|
+
return {
|
|
915
|
+
ready,
|
|
916
|
+
rows: () => (state ? (state.list() as T[]) : []),
|
|
917
|
+
get key() {
|
|
918
|
+
return key;
|
|
919
|
+
},
|
|
920
|
+
unsubscribe: async () => {
|
|
921
|
+
if (gone) return;
|
|
922
|
+
gone = true;
|
|
923
|
+
const sub = await attached.catch(() => null);
|
|
924
|
+
if (!sub) return;
|
|
925
|
+
sub.listeners.delete(listener);
|
|
926
|
+
if (sub.listeners.size > 0 || this.subs.get(sub.key) !== sub) return;
|
|
927
|
+
this.subs.delete(sub.key);
|
|
928
|
+
if (sub.flushTimer) clearTimeout(sub.flushTimer);
|
|
929
|
+
if (sub.rereadTimer) {
|
|
930
|
+
clearTimeout(sub.rereadTimer);
|
|
931
|
+
this.busy--;
|
|
932
|
+
}
|
|
933
|
+
if (sub.registered && !this.isClosed) {
|
|
934
|
+
await this.q('fn::query::unsubscribe($id)', { id: sub.rid }).catch((error) =>
|
|
935
|
+
this.report(error)
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
idle(options: LiveIdleOptions = {}): Promise<boolean> {
|
|
943
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
944
|
+
const quietMs = options.quietMs ?? 300;
|
|
945
|
+
const deadline = Date.now() + timeoutMs;
|
|
946
|
+
return new Promise<boolean>((resolve) => {
|
|
947
|
+
const check = () => {
|
|
948
|
+
if (this.isClosed) return resolve(false);
|
|
949
|
+
const loading =
|
|
950
|
+
this.starting > 0 || [...this.subs.values()].some((s) => !s.loaded && !s.failed);
|
|
951
|
+
const flushing = [...this.subs.values()].some((s) => s.flushTimer !== null);
|
|
952
|
+
const quietFor = Date.now() - this.lastActivity;
|
|
953
|
+
if (!loading && !flushing && this.busy === 0 && quietFor >= quietMs) return resolve(true);
|
|
954
|
+
const left = deadline - Date.now();
|
|
955
|
+
if (left <= 0) return resolve(false);
|
|
956
|
+
setTimeout(
|
|
957
|
+
check,
|
|
958
|
+
Math.max(10, Math.min(50, left, quietMs - quietFor > 0 ? quietMs - quietFor : 50))
|
|
959
|
+
);
|
|
960
|
+
};
|
|
961
|
+
check();
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async close(): Promise<void> {
|
|
966
|
+
if (this.isClosed) return;
|
|
967
|
+
const db = this.db;
|
|
968
|
+
const views = [...this.subs.values()].filter((s) => s.registered).map((s) => s.rid);
|
|
969
|
+
const live = this.live;
|
|
970
|
+
// Release while the socket is still ours, then mark closed.
|
|
971
|
+
if (db) {
|
|
972
|
+
if (live) {
|
|
973
|
+
live.off();
|
|
974
|
+
await withTimeout(
|
|
975
|
+
Promise.resolve(
|
|
976
|
+
live.stream.kill ? live.stream.kill() : db.query('KILL $u', { u: live.id })
|
|
977
|
+
),
|
|
978
|
+
RELEASE_TIMEOUT_MS,
|
|
979
|
+
'kill timed out'
|
|
980
|
+
).catch(() => undefined);
|
|
981
|
+
}
|
|
982
|
+
if (views.length > 0) {
|
|
983
|
+
const vars: Record<string, unknown> = {};
|
|
984
|
+
const sql = views
|
|
985
|
+
.map((id, i) => {
|
|
986
|
+
vars[`id${i}`] = id;
|
|
987
|
+
return `fn::query::unsubscribe($id${i})`;
|
|
988
|
+
})
|
|
989
|
+
.join(';\n');
|
|
990
|
+
await withTimeout(
|
|
991
|
+
Promise.resolve(db.query(sql, vars)),
|
|
992
|
+
RELEASE_TIMEOUT_MS,
|
|
993
|
+
'release timed out'
|
|
994
|
+
).catch(() => undefined);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
this.isClosed = true;
|
|
998
|
+
this.live = null;
|
|
999
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
1000
|
+
this.heartbeatTimer = null;
|
|
1001
|
+
if (this.fetchTimer) clearTimeout(this.fetchTimer);
|
|
1002
|
+
this.fetchTimer = null;
|
|
1003
|
+
for (const sub of this.subs.values()) {
|
|
1004
|
+
if (sub.flushTimer) clearTimeout(sub.flushTimer);
|
|
1005
|
+
if (sub.rereadTimer) clearTimeout(sub.rereadTimer);
|
|
1006
|
+
if (!sub.loaded) sub.ready.reject(new LiveFeedError('closed', 'live feed is closed'));
|
|
1007
|
+
}
|
|
1008
|
+
this.subs.clear();
|
|
1009
|
+
for (const off of this.offs.splice(0)) off();
|
|
1010
|
+
if (db) await db.close().catch(() => undefined);
|
|
1011
|
+
this.db = null;
|
|
1012
|
+
}
|
|
1013
|
+
}
|