@ultimat3/realtime 20.2.1 → 21.0.0
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/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/errors.ts
CHANGED
|
@@ -18,12 +18,19 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
18
18
|
'X_REPLICATION_PROTOCOL',
|
|
19
19
|
'X_REPLICATION_FAILED',
|
|
20
20
|
'X_REPLICATOR_SLOT_HELD',
|
|
21
|
+
// Retired in 21.0.0 and never thrown since — kept registered, because a shipped code is
|
|
22
|
+
// forever: a log line from an older build still resolves through `x errors explain`.
|
|
21
23
|
'X_LIVE_CLIENT_MISSING',
|
|
24
|
+
'X_QUERY_NOT_SUBSCRIBABLE',
|
|
25
|
+
'X_REALTIME_UNINSTALLED',
|
|
26
|
+
'X_SYNC_UNCONFIGURED',
|
|
27
|
+
'X_RECORD_REJECTED',
|
|
28
|
+
'X_CHANNEL_DECLARATION_INVALID',
|
|
29
|
+
'X_LOCAL_STORE_UNAVAILABLE',
|
|
22
30
|
'X_LIVE_SERVER_RENDER',
|
|
23
31
|
'X_LIVE_ROW_UNIDENTIFIED',
|
|
24
32
|
'X_LIVE_QUERY_UNKNOWN',
|
|
25
33
|
'X_LIVE_REPLICA_IDENTITY',
|
|
26
|
-
'X_QUERY_NOT_SUBSCRIBABLE',
|
|
27
34
|
'X_SOCKET_UNAUTHENTICATED',
|
|
28
35
|
'X_SOCKET_AUTH_UNAVAILABLE',
|
|
29
36
|
] as const;
|
|
@@ -118,12 +125,19 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
118
125
|
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
119
126
|
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
120
127
|
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
121
|
-
X_LIVE_CLIENT_MISSING:
|
|
128
|
+
X_LIVE_CLIENT_MISSING:
|
|
129
|
+
'retired in 21.0.0 (no LiveClient to register; see X_REALTIME_UNINSTALLED): a realtime hook ran in a browser with no LiveClient registered',
|
|
130
|
+
X_QUERY_NOT_SUBSCRIBABLE:
|
|
131
|
+
'retired in 21.0.0 (liveHookFor was deleted; useQuery reads any query): a hook was bound to a query that is not declared live',
|
|
132
|
+
X_REALTIME_UNINSTALLED: 'a realtime hook ran in an island bundle that never installed realtime',
|
|
133
|
+
X_SYNC_UNCONFIGURED: 'a live hook needed the page socket and no sync target was configured',
|
|
134
|
+
X_RECORD_REJECTED: 'a row reached the record store in a shape it cannot hold',
|
|
135
|
+
X_CHANNEL_DECLARATION_INVALID: 'a channel() declaration cannot route its rows',
|
|
136
|
+
X_LOCAL_STORE_UNAVAILABLE: "the page's durable store could not open",
|
|
122
137
|
X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
|
|
123
138
|
X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
|
|
124
139
|
X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
|
|
125
140
|
X_LIVE_REPLICA_IDENTITY: 'a replicated table sends a key-only row on delete',
|
|
126
|
-
X_QUERY_NOT_SUBSCRIBABLE: 'a hook was bound to a query that is not declared live',
|
|
127
141
|
X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
|
|
128
142
|
X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
|
|
129
143
|
};
|
|
@@ -136,10 +150,21 @@ registerErrorCodes(
|
|
|
136
150
|
),
|
|
137
151
|
);
|
|
138
152
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
export {
|
|
154
|
+
CursorStaleError,
|
|
155
|
+
LocalStoreUnavailableError,
|
|
156
|
+
ProtocolVersionError,
|
|
157
|
+
RealtimeUninstalledError,
|
|
158
|
+
RebaseConflictError,
|
|
159
|
+
RecordRejectedError,
|
|
160
|
+
ServerRenderLiveError,
|
|
161
|
+
SyncUnconfiguredError,
|
|
162
|
+
} from './page-errors';
|
|
163
|
+
// Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts`, the four
|
|
164
|
+
// replication errors in `replication-errors.ts` and the browser-reachable refusals in
|
|
165
|
+
// `page-errors.ts`, so this file is the CODE TABLE plus the server's refusals. Every name is still
|
|
166
|
+
// importable from `./errors`, which is what the `pg-*` modules and the barrel already do; a
|
|
167
|
+
// browser module imports `./page-errors` so it does not load the table.
|
|
143
168
|
export { RealtimeError } from './realtime-error';
|
|
144
169
|
export {
|
|
145
170
|
ReplicaIdentityError,
|
|
@@ -154,7 +179,8 @@ export class TopicForbiddenError extends RealtimeError {
|
|
|
154
179
|
super({
|
|
155
180
|
code: 'X_TOPIC_FORBIDDEN',
|
|
156
181
|
cause: `actor ${args.actorId ?? '<anonymous>'} may not subscribe to "${args.topic}": ${args.reason}`,
|
|
157
|
-
|
|
182
|
+
// The topic's first segment is its channel's name; the policy on that declaration decides.
|
|
183
|
+
fix: 'x policy list --json # then widen the policy on the channel() declaration this topic belongs to, or subscribe as an actor it allows',
|
|
158
184
|
});
|
|
159
185
|
}
|
|
160
186
|
}
|
|
@@ -217,44 +243,6 @@ export class SubscriptionIdTakenError extends RealtimeError {
|
|
|
217
243
|
}
|
|
218
244
|
}
|
|
219
245
|
|
|
220
|
-
/**
|
|
221
|
-
* Client and server disagree on the wire format — a version mismatch or a malformed frame.
|
|
222
|
-
* Both are the same class of bug (a peer speaking a shape we do not have), so both get one code.
|
|
223
|
-
*/
|
|
224
|
-
export class ProtocolVersionError extends RealtimeError {
|
|
225
|
-
constructor(args: { got: unknown; expected: number; detail?: string }) {
|
|
226
|
-
super({
|
|
227
|
-
code: 'X_PROTOCOL_VERSION',
|
|
228
|
-
cause:
|
|
229
|
-
args.detail ??
|
|
230
|
-
`frame protocol version ${String(args.got)} is not the server version ${args.expected}`,
|
|
231
|
-
fix: 'x build && redeploy the client; the sync node sends `update-available` before it drains',
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** A resume cursor cannot be honoured and no snapshot path was supplied. */
|
|
237
|
-
export class CursorStaleError extends RealtimeError {
|
|
238
|
-
constructor(args: { qid: string; lsn: string; reason: string }) {
|
|
239
|
-
super({
|
|
240
|
-
code: 'X_CURSOR_STALE',
|
|
241
|
-
cause: `cursor for query ${args.qid} at lsn ${args.lsn} cannot be resumed: ${args.reason}`,
|
|
242
|
-
fix: 'pass `snapshot` to resumeFrom() so the fallback path can re-snapshot instead of failing',
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** A rebase could not be resolved: `custom(merge)` returned nothing, or the base row vanished. */
|
|
248
|
-
export class RebaseConflictError extends RealtimeError {
|
|
249
|
-
constructor(args: { key: string; entity: string; reason: string }) {
|
|
250
|
-
super({
|
|
251
|
-
code: 'X_REBASE_CONFLICT',
|
|
252
|
-
cause: `mutation ${args.key} on ${args.entity} could not be rebased: ${args.reason}`,
|
|
253
|
-
fix: "set conflict: 'server-wins' on the mutator, or return a row from custom(merge)",
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
246
|
/** The fanout bus is down. `sync` nodes are stateless, so this is always recoverable. */
|
|
259
247
|
export class TransportUnavailableError extends RealtimeError {
|
|
260
248
|
constructor(args: { transport: string; reason: string; fix?: string }) {
|
|
@@ -285,44 +273,6 @@ export class TransportProtocolError extends RealtimeError {
|
|
|
285
273
|
}
|
|
286
274
|
}
|
|
287
275
|
|
|
288
|
-
/**
|
|
289
|
-
* A hook was called IN A BROWSER before the app entry registered its client. Never a transient
|
|
290
|
-
* fault: the registration is a single call in the entry, so the fix is the call itself rather than
|
|
291
|
-
* a retry.
|
|
292
|
-
*
|
|
293
|
-
* A server render is deliberately not this error, and never was a missing registration: there is
|
|
294
|
-
* no socket to register a client for. It gets `serverRenderLiveClient()` instead — the same rule
|
|
295
|
-
* `@ultimat3/ui`'s `solid()` follows for a missing Solid runtime, one package over.
|
|
296
|
-
*/
|
|
297
|
-
export class LiveClientMissingError extends RealtimeError {
|
|
298
|
-
constructor(args: { hook: string }) {
|
|
299
|
-
super({
|
|
300
|
-
code: 'X_LIVE_CLIENT_MISSING',
|
|
301
|
-
cause: `${args.hook}() ran in a browser before any LiveClient was registered`,
|
|
302
|
-
fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
309
|
-
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
|
310
|
-
* will be one: the document is built and sent, and the browser opens the connection.
|
|
311
|
-
*
|
|
312
|
-
* A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
|
|
313
|
-
* hold one process-wide queue on behalf of whichever request happened to render, and dropping it
|
|
314
|
-
* would make a write that never happened look like one that did.
|
|
315
|
-
*/
|
|
316
|
-
export class ServerRenderLiveError extends RealtimeError {
|
|
317
|
-
constructor(args: { operation: string }) {
|
|
318
|
-
super({
|
|
319
|
-
code: 'X_LIVE_SERVER_RENDER',
|
|
320
|
-
cause: `${args.operation} ran during a server render, where this app has no live socket`,
|
|
321
|
-
fix: 'call it from an island mount() instead of from the page — or guard it with hasLiveClient(), which answers false on the server',
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
276
|
/**
|
|
327
277
|
* A subscribable read projected a row with no `id`. Patches, cursors and the local store all
|
|
328
278
|
* address a row by `id`, so such a row cannot be delivered — and delivering it anyway produces a
|
|
@@ -362,23 +312,6 @@ export class LiveQueryUnknownError extends RealtimeError {
|
|
|
362
312
|
}
|
|
363
313
|
}
|
|
364
314
|
|
|
365
|
-
/**
|
|
366
|
-
* `liveHookFor` was handed a read that never patches. Refused where the binding is written rather
|
|
367
|
-
* than at the first render, because a hook over a non-live query has nothing to subscribe to — it
|
|
368
|
-
* would return an empty set forever and look like a policy denial or an empty table.
|
|
369
|
-
*/
|
|
370
|
-
export class QueryNotSubscribableError extends RealtimeError {
|
|
371
|
-
constructor(args: { name: string }) {
|
|
372
|
-
super({
|
|
373
|
-
code: 'X_QUERY_NOT_SUBSCRIBABLE',
|
|
374
|
-
// Empty at module load, when the binding runs and `registerQueries()` has not stamped a
|
|
375
|
-
// name yet — say so rather than printing `query ""`.
|
|
376
|
-
cause: `query ${args.name === '' ? '<unregistered>' : `"${args.name}"`} is not declared live: true, so it has no subscription for a hook to read`,
|
|
377
|
-
fix: 'add live: true to the query declaration, or read it once through query.client({ baseUrl }) — wiki/Queries-And-Live-Queries.md',
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
315
|
/**
|
|
383
316
|
* The app's `authenticate` decided this upgrade belongs to nobody. A **decision**, so it is the
|
|
384
317
|
* client's own condition and never pages anyone: the refusal is the whole point of the hook.
|
package/src/frame-lanes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The order this node applies one socket's inbound frames in. `sync-node.message` dispatches every
|
|
2
2
|
// frame as `void (async () => routeFrame(…))()`, so nothing upstream orders them and a router that
|
|
3
|
-
// awaits a policy
|
|
3
|
+
// awaits a policy or a snapshot read finishes in whatever order those settle.
|
|
4
4
|
//
|
|
5
5
|
// **Not one lane per socket.** A global per-socket lane puts every frame behind the slowest one,
|
|
6
6
|
// and the slowest one is a snapshot read — a database round trip that every reconnecting client
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// | Frames | Lane | Why that is the unit |
|
|
11
11
|
// |---|---|---|
|
|
12
|
-
// | `mutate` | `mutate` (one per socket) | they write the database, and the client numbered them |
|
|
13
12
|
// | `subscribe` on a query | `sub:<sid>` | `add` then `drop` for one sid, or the drop finds nothing and the add strands the subscription it was meant to end |
|
|
14
|
-
// | `subscribe` on a
|
|
13
|
+
// | `subscribe` on a channel | `channel:<name>:<params>` | the same add/drop pair, one membership |
|
|
15
14
|
// | `hello`, server-authored kinds | none | they read state and write none of it |
|
|
16
15
|
//
|
|
17
16
|
// The caps are NOT this file's job — a lane makes concurrent frames sequential, and N sequential
|
|
@@ -52,7 +51,12 @@ export class FrameLanes {
|
|
|
52
51
|
|
|
53
52
|
/** The lane a frame belongs in, or `null` for the kinds nothing has to order. */
|
|
54
53
|
export function laneKeyOf(frame: Frame): string | null {
|
|
55
|
-
if (frame.type === 'mutate') return 'mutate';
|
|
56
54
|
if (frame.type !== 'subscribe') return null;
|
|
57
|
-
|
|
55
|
+
const target = frame.target;
|
|
56
|
+
if (target.kind !== 'channel') return `sub:${frame.sid}`;
|
|
57
|
+
// The channel's TOPIC, spelled from what fixes it — the declaration name and its params — so an
|
|
58
|
+
// add and a drop of one topic queue behind each other whatever sids they carry. Sorted, so two
|
|
59
|
+
// spellings of one params object are one lane. Entry order is sorted, never trusted.
|
|
60
|
+
const params = Object.entries(target.params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
61
|
+
return `channel:${target.channel}:${JSON.stringify(params)}`;
|
|
58
62
|
}
|
package/src/idb-fake.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory IndexedDB with exactly the surface `local-store-idb.ts` uses — open with an
|
|
3
|
+
* upgrade, one transaction over named stores, `put` / `delete` / `getAll` / `getAllKeys` — and
|
|
4
|
+
* IndexedDB's own asynchrony: every request answers on a later microtask and a transaction
|
|
5
|
+
* completes after its last request. Bun ships no IndexedDB, and no dependency is worth a test seam.
|
|
6
|
+
* Values are structured-cloned in and out, as the real one does.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike, IdbStoreLike } from './idb-types';
|
|
10
|
+
|
|
11
|
+
type Tables = Map<string, Map<string, unknown>>;
|
|
12
|
+
|
|
13
|
+
export interface FakeIdbOptions {
|
|
14
|
+
/** `open` fails, as it does in a private window or with storage blocked. */
|
|
15
|
+
readonly blocked?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A fresh, empty database server. Share one instance to simulate a reload over the same disk. */
|
|
19
|
+
export function fakeIndexedDb(options: FakeIdbOptions = {}): IdbFactoryLike {
|
|
20
|
+
const databases = new Map<string, { version: number; tables: Tables }>();
|
|
21
|
+
return {
|
|
22
|
+
open(name: string, version: number): IdbRequestLike<IdbDatabaseLike> {
|
|
23
|
+
const request = pendingRequest<IdbDatabaseLike>();
|
|
24
|
+
queueMicrotask(() => {
|
|
25
|
+
if (options.blocked === true) {
|
|
26
|
+
request.fail(new DOMException('The operation is insecure.', 'SecurityError'));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const known = databases.get(name) ?? { version: 0, tables: new Map() };
|
|
30
|
+
databases.set(name, known);
|
|
31
|
+
const db = database(known.tables);
|
|
32
|
+
request.result = db;
|
|
33
|
+
if (known.version < version) {
|
|
34
|
+
known.version = version;
|
|
35
|
+
request.onupgradeneeded?.();
|
|
36
|
+
}
|
|
37
|
+
request.onsuccess?.();
|
|
38
|
+
});
|
|
39
|
+
return request;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function database(tables: Tables): IdbDatabaseLike {
|
|
45
|
+
return {
|
|
46
|
+
objectStoreNames: { contains: (name: string): boolean => tables.has(name) },
|
|
47
|
+
createObjectStore: (name: string): void => {
|
|
48
|
+
tables.set(name, new Map());
|
|
49
|
+
},
|
|
50
|
+
transaction(names: string | readonly string[]) {
|
|
51
|
+
let open = 0;
|
|
52
|
+
const failed = false;
|
|
53
|
+
const tx = {
|
|
54
|
+
oncomplete: null as (() => void) | null,
|
|
55
|
+
onerror: null as (() => void) | null,
|
|
56
|
+
error: null as unknown,
|
|
57
|
+
objectStore: (name: string): IdbStoreLike => store(name),
|
|
58
|
+
};
|
|
59
|
+
const settleLater = (): void => {
|
|
60
|
+
queueMicrotask(() => {
|
|
61
|
+
if (open > 0 || failed) return;
|
|
62
|
+
tx.oncomplete?.();
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const run = <T>(work: () => T): IdbRequestLike<T> => {
|
|
66
|
+
const request = pendingRequest<T>();
|
|
67
|
+
open += 1;
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
open -= 1;
|
|
70
|
+
request.result = work();
|
|
71
|
+
request.onsuccess?.();
|
|
72
|
+
settleLater();
|
|
73
|
+
});
|
|
74
|
+
return request;
|
|
75
|
+
};
|
|
76
|
+
const store = (name: string): IdbStoreLike => {
|
|
77
|
+
const allowed = typeof names === 'string' ? [names] : names;
|
|
78
|
+
const table = tables.get(name);
|
|
79
|
+
if (!allowed.includes(name) || table === undefined) {
|
|
80
|
+
throw new DOMException(`no object store ${name}`, 'NotFoundError');
|
|
81
|
+
}
|
|
82
|
+
const sorted = (): [string, unknown][] =>
|
|
83
|
+
[...table].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
84
|
+
return {
|
|
85
|
+
put: (value, key) => run(() => void table.set(key, structuredClone(value))),
|
|
86
|
+
delete: (key) => run(() => void table.delete(key)),
|
|
87
|
+
getAll: () => run(() => sorted().map(([, value]) => structuredClone(value))),
|
|
88
|
+
getAllKeys: () => run(() => sorted().map(([key]) => key)),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
settleLater();
|
|
92
|
+
return tx;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type PendingRequest<T> = IdbRequestLike<T> & { fail(error: unknown): void };
|
|
98
|
+
|
|
99
|
+
function pendingRequest<T>(): PendingRequest<T> {
|
|
100
|
+
const request: PendingRequest<T> = {
|
|
101
|
+
result: undefined as T,
|
|
102
|
+
error: null,
|
|
103
|
+
onsuccess: null,
|
|
104
|
+
onerror: null,
|
|
105
|
+
onupgradeneeded: null,
|
|
106
|
+
onblocked: null,
|
|
107
|
+
fail(error: unknown): void {
|
|
108
|
+
request.error = error;
|
|
109
|
+
request.onerror?.();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
return request;
|
|
113
|
+
}
|
package/src/idb-types.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of IndexedDB `local-store-idb.ts` touches, as structural types: the browser's
|
|
3
|
+
* `indexedDB` satisfies them and so does `idb-fake.ts`, so the store is tested against the same
|
|
4
|
+
* surface it runs on without a DOM shim.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface IdbRequestLike<T> {
|
|
8
|
+
result: T;
|
|
9
|
+
error: unknown;
|
|
10
|
+
onsuccess: (() => void) | null;
|
|
11
|
+
onerror: (() => void) | null;
|
|
12
|
+
onupgradeneeded?: (() => void) | null;
|
|
13
|
+
onblocked?: (() => void) | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface IdbStoreLike {
|
|
17
|
+
put(value: unknown, key: string): IdbRequestLike<unknown>;
|
|
18
|
+
delete(key: string): IdbRequestLike<unknown>;
|
|
19
|
+
getAll(): IdbRequestLike<unknown[]>;
|
|
20
|
+
getAllKeys(): IdbRequestLike<unknown[]>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IdbTransactionLike {
|
|
24
|
+
oncomplete: (() => void) | null;
|
|
25
|
+
onerror: (() => void) | null;
|
|
26
|
+
error: unknown;
|
|
27
|
+
objectStore(name: string): IdbStoreLike;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IdbDatabaseLike {
|
|
31
|
+
readonly objectStoreNames: { contains(name: string): boolean };
|
|
32
|
+
createObjectStore(name: string): unknown;
|
|
33
|
+
transaction(
|
|
34
|
+
names: string | readonly string[],
|
|
35
|
+
mode?: 'readonly' | 'readwrite',
|
|
36
|
+
): IdbTransactionLike;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IdbFactoryLike {
|
|
40
|
+
open(name: string, version: number): IdbRequestLike<IdbDatabaseLike>;
|
|
41
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
1
1
|
// The CLIENT half of the public API — everything a browser island may bundle. Explicit, tier by
|
|
2
|
-
// tier: the wire, the hooks, the
|
|
2
|
+
// tier: the wire, the hooks, the record store, the offline outbox and the reconnect vocabulary.
|
|
3
3
|
// Nothing here reaches `nats`, a Postgres socket or the sync node; those are `./server`, and
|
|
4
4
|
// `packages/cli/src/realtime-browser-barrel.test.ts` is the build error that keeps them apart.
|
|
5
5
|
|
|
6
6
|
// ---- the client's one stateless piece, reusable against an app's own store ----------------------
|
|
7
7
|
export { applyPatches, orderAfterPatches } from './apply-patches';
|
|
8
|
-
// ----
|
|
8
|
+
// ---- channels: the declaration is the only way to spell a topic, and the frames it rides --------
|
|
9
9
|
export {
|
|
10
|
-
type
|
|
11
|
-
|
|
12
|
-
type
|
|
13
|
-
type
|
|
14
|
-
type
|
|
15
|
-
type
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
type Channel,
|
|
11
|
+
type ChannelEntity,
|
|
12
|
+
type ChannelInit,
|
|
13
|
+
type ChannelParams,
|
|
14
|
+
type ChannelRowLoader,
|
|
15
|
+
type ChannelServerInit,
|
|
16
|
+
channel,
|
|
17
|
+
} from './channel-decl';
|
|
18
|
+
// Presence rides a channel's `events`: the payload shape, and the one reader a client needs.
|
|
19
|
+
export { type PresenceEvent, type PresenceOp, readPresence } from './channel-presence';
|
|
20
|
+
// The client half of a channel an island holds — no entity, no policy (`channel-ref.ts`).
|
|
21
|
+
export { type ChannelHandle, channelRef, type Topic, topic } from './channel-ref';
|
|
22
|
+
// The process's channel table: what a hub serves by default. `describeChannels` is `./server`'s.
|
|
23
|
+
export { clearChannels, registeredChannels } from './channel-registry';
|
|
24
|
+
export type {
|
|
25
|
+
ChannelAdopt,
|
|
26
|
+
ChannelEventsFrame,
|
|
27
|
+
ChannelRecordsFrame,
|
|
28
|
+
ChannelRemove,
|
|
29
|
+
ChannelSince,
|
|
30
|
+
ChannelSubscribeTarget,
|
|
31
|
+
ChannelWireFrame,
|
|
32
|
+
ReplayGapFrame,
|
|
33
|
+
} from './channel-wire';
|
|
34
|
+
// ---- the hooks: every read and write a component makes -----------------------------------------
|
|
35
|
+
export type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
|
|
36
|
+
// ---- the shapes a hook hands back ----------------------------------------------------------------
|
|
37
|
+
export type {
|
|
38
|
+
LiveHandle,
|
|
39
|
+
LiveQueryRef,
|
|
40
|
+
SignalFactory,
|
|
41
|
+
Unsubscribe,
|
|
42
|
+
} from './client-contract';
|
|
21
43
|
// ---- reconnect ----------------------------------------------------------------------------------
|
|
22
44
|
export {
|
|
23
45
|
advance,
|
|
@@ -38,54 +60,29 @@ export {
|
|
|
38
60
|
export {
|
|
39
61
|
CursorStaleError,
|
|
40
62
|
FrameRateLimitError,
|
|
41
|
-
LiveClientMissingError,
|
|
42
63
|
LiveQueryUnknownError,
|
|
43
64
|
LiveRowUnidentifiedError,
|
|
44
65
|
NotImplementedError,
|
|
45
66
|
ProtocolVersionError,
|
|
46
|
-
QueryNotSubscribableError,
|
|
47
67
|
REALTIME_ERROR_CODES,
|
|
48
68
|
REALTIME_ERROR_TITLES,
|
|
49
69
|
RealtimeError,
|
|
50
70
|
type RealtimeErrorCode,
|
|
71
|
+
RealtimeUninstalledError,
|
|
51
72
|
RebaseConflictError,
|
|
73
|
+
RecordRejectedError,
|
|
52
74
|
ReplicaIdentityError,
|
|
53
75
|
ReplicationFailedError,
|
|
54
76
|
ReplicationProtocolError,
|
|
55
77
|
ReplicatorSlotHeldError,
|
|
56
78
|
ServerRenderLiveError,
|
|
57
79
|
SubscriptionLimitError,
|
|
80
|
+
SyncUnconfiguredError,
|
|
58
81
|
TopicForbiddenError,
|
|
59
82
|
TransportProtocolError,
|
|
60
83
|
TransportUnavailableError,
|
|
61
84
|
WindowReadTimeoutError,
|
|
62
85
|
} from './errors';
|
|
63
|
-
// ---- the client hooks --------------------------------------------------------------------------
|
|
64
|
-
export {
|
|
65
|
-
type ConflictLike,
|
|
66
|
-
type Connection,
|
|
67
|
-
clearLiveClient,
|
|
68
|
-
hasLiveClient,
|
|
69
|
-
type LiveInput,
|
|
70
|
-
type LiveRows,
|
|
71
|
-
type Mutate,
|
|
72
|
-
type MutationQueue,
|
|
73
|
-
type MutatorLike,
|
|
74
|
-
setLiveClient,
|
|
75
|
-
useConnection,
|
|
76
|
-
useLive,
|
|
77
|
-
useMutation,
|
|
78
|
-
useMutationQueue,
|
|
79
|
-
} from './hooks';
|
|
80
|
-
// ---- the client's single source of truth: one row per (entity, id) ------------------------------
|
|
81
|
-
export {
|
|
82
|
-
type IdentityListener,
|
|
83
|
-
IdentityMap,
|
|
84
|
-
privateScope,
|
|
85
|
-
type RowKey,
|
|
86
|
-
type RowScope,
|
|
87
|
-
rowKey,
|
|
88
|
-
} from './identity-map';
|
|
89
86
|
// ---- shared value domain ---------------------------------------------------------------------
|
|
90
87
|
export {
|
|
91
88
|
changedColumns,
|
|
@@ -97,54 +94,48 @@ export {
|
|
|
97
94
|
type RowOp,
|
|
98
95
|
type RowPatch,
|
|
99
96
|
} from './json';
|
|
100
|
-
export { type Registration, RowWindows } from './live-rows';
|
|
101
|
-
// ----
|
|
97
|
+
export { type LiveState, type Registration, RowWindows, unnamedType } from './live-rows';
|
|
98
|
+
// ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
|
|
102
99
|
export {
|
|
103
|
-
createOpfsLocalStore,
|
|
104
100
|
type LocalStore,
|
|
105
|
-
type LocalTable,
|
|
106
|
-
type LocalTx,
|
|
107
101
|
MemoryLocalStore,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
102
|
+
openLocalStore,
|
|
103
|
+
pageLocalStore,
|
|
104
|
+
scopeKey,
|
|
105
|
+
} from './local-store-idb';
|
|
106
|
+
// ---- the offline outbox (wired over HTTP in plan 101 slice 12) ----------------------------------
|
|
111
107
|
export {
|
|
112
108
|
type DrainReport,
|
|
113
109
|
MemoryQueueStore,
|
|
114
110
|
type MutationSender,
|
|
115
111
|
type MutationStatus,
|
|
116
|
-
mutateFrame,
|
|
117
112
|
OfflineQueue,
|
|
118
113
|
type QueuedMutation,
|
|
119
114
|
type QueueState,
|
|
120
115
|
type QueueStore,
|
|
121
116
|
} from './offline-queue';
|
|
122
|
-
/** The typed projection: one query bound to one named hook, `useLiveFeed({ orgId })`. */
|
|
123
117
|
export {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
createOutbox,
|
|
119
|
+
listenForDrain,
|
|
120
|
+
type OutboxEntry,
|
|
121
|
+
type PageOutbox,
|
|
122
|
+
pageOutbox,
|
|
123
|
+
} from './page-outbox';
|
|
124
|
+
// ---- the page: one record store, one socket, installed by the island bootstrap ------------------
|
|
125
|
+
export { hasPageSocket, type SyncTarget } from './page-store';
|
|
126
|
+
export { installRealtime, type RealtimeInstall } from './reactivity';
|
|
127
|
+
export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
128
128
|
export {
|
|
129
|
-
type
|
|
130
|
-
type
|
|
131
|
-
|
|
132
|
-
type
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
type ReconcileResult,
|
|
137
|
-
rebaseFrame,
|
|
138
|
-
reconcile,
|
|
139
|
-
type ServerAck,
|
|
140
|
-
strategyName,
|
|
141
|
-
} from './rebase';
|
|
142
|
-
// ---- what a LiveClient IS on the server: it serves the first render and opens no socket --------
|
|
143
|
-
export { serverRenderLiveClient } from './server-render-client';
|
|
129
|
+
type RecordKey,
|
|
130
|
+
type RecordListener,
|
|
131
|
+
RecordStore,
|
|
132
|
+
type RecordStoreOptions,
|
|
133
|
+
recordKey,
|
|
134
|
+
} from './record-store';
|
|
135
|
+
export type { LocalTable, LocalTx, TableMap } from './record-tx';
|
|
144
136
|
// ---- the wire -------------------------------------------------------------------------------------
|
|
145
137
|
export {
|
|
146
138
|
type AckFrame,
|
|
147
|
-
type ConflictStrategyName,
|
|
148
139
|
decode,
|
|
149
140
|
encode,
|
|
150
141
|
FRAME_KINDS,
|
|
@@ -152,12 +143,9 @@ export {
|
|
|
152
143
|
type Frame,
|
|
153
144
|
type FrameKind,
|
|
154
145
|
type HelloFrame,
|
|
155
|
-
type MutateFrame,
|
|
156
146
|
type PatchFrame,
|
|
157
147
|
PROTOCOL_VERSION,
|
|
158
|
-
type PresenceFrame,
|
|
159
148
|
type PresenceMember,
|
|
160
|
-
type RebaseFrame,
|
|
161
149
|
type ReconnectFrame,
|
|
162
150
|
type SnapshotFrame,
|
|
163
151
|
type SubscribeFrame,
|
|
@@ -169,7 +157,9 @@ export {
|
|
|
169
157
|
// ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
|
|
170
158
|
export {
|
|
171
159
|
type BackoffPolicy,
|
|
160
|
+
BROWSER_RECONNECT_MAX_MS,
|
|
172
161
|
backoffDelay,
|
|
162
|
+
browserBackoff,
|
|
173
163
|
defaultBackoff,
|
|
174
164
|
type JitterMode,
|
|
175
165
|
type ReconnectReason,
|
|
@@ -177,3 +167,19 @@ export {
|
|
|
177
167
|
type Scheduler,
|
|
178
168
|
timeoutScheduler,
|
|
179
169
|
} from './thundering-herd';
|
|
170
|
+
export {
|
|
171
|
+
type ChannelAccessor,
|
|
172
|
+
type PresenceAccessor,
|
|
173
|
+
useChannel,
|
|
174
|
+
usePresence,
|
|
175
|
+
} from './use-channel';
|
|
176
|
+
export { type Connection, useConnection } from './use-connection';
|
|
177
|
+
export {
|
|
178
|
+
type Mutate,
|
|
179
|
+
type MutationQueue,
|
|
180
|
+
type MutatorLike,
|
|
181
|
+
useMutation,
|
|
182
|
+
useMutationQueue,
|
|
183
|
+
} from './use-mutation';
|
|
184
|
+
export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
|
|
185
|
+
export { type RecordAccessor, type RecordsAccessor, useRecord, useRecords } from './use-record';
|
package/src/json.ts
CHANGED
|
@@ -34,6 +34,11 @@ export interface RowPatch {
|
|
|
34
34
|
readonly row: JsonObject | null;
|
|
35
35
|
readonly lsn: string;
|
|
36
36
|
readonly index?: number;
|
|
37
|
+
/**
|
|
38
|
+
* The row's RECORD key when it is not its `id` — the entity's primary key, rendered by its
|
|
39
|
+
* projection on the server. Absent means the key is the `id`.
|
|
40
|
+
*/
|
|
41
|
+
readonly key?: string;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function isJsonObject(value: unknown): value is JsonObject {
|
package/src/live-contract.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface LiveQueryDefinition<R extends Row = Row> {
|
|
|
41
41
|
* rows private to that one subscription rather than guessing.
|
|
42
42
|
*/
|
|
43
43
|
rowEntity?(input: JsonValue): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* The record key each row travels under, resolved with `rowEntity`: the entity's own projection.
|
|
46
|
+
* `null` (or absent) is a plain table, whose rows are keyed by `id`.
|
|
47
|
+
*/
|
|
48
|
+
rowKey?(input: JsonValue): ((row: Row) => string) | null;
|
|
44
49
|
/**
|
|
45
50
|
* Resolve whatever this input needs before an entry is built. `matcher` is synchronous by
|
|
46
51
|
* design — a change event must not await anything — so a definition that has to compile a
|