@ultimat3/realtime 20.2.1 → 22.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 +300 -952
- package/README.md +192 -131
- 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 +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -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 +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- 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 +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- 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 +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- 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 +227 -0
- package/src/use-query.ts +260 -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/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Three tiers, one ladder, one protocol. Climbing a rung is a config change, never
|
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| **1 — channels** | `publish`/`subscribe` on typed topics, presence, cursors, typing indicators | ~0. One filtered `send` per subscribed socket — no DB, no replication slot. **At most once**: a frame backpressure drops is counted, never replayed |
|
|
10
10
|
| **2 — live queries** | the list updates when someone else edits; your own click feels instant | one change feed + a matcher per query id + a bounded change window |
|
|
11
|
-
| **3 — local-first** | writes that survive being offline | a durable
|
|
11
|
+
| **3 — local-first** | writes that survive being offline | a durable outbox, client-side migrations, a conflict story per mutator (plan 101 slice 12) |
|
|
12
12
|
|
|
13
13
|
Tier 2 covers ~90% of "make it realtime". Tier 3 buys exactly one extra property — offline writes — and charges a client database for it. Do not buy it by accident.
|
|
14
14
|
|
|
@@ -25,35 +25,34 @@ export const liveFeed = query({
|
|
|
25
25
|
|
|
26
26
|
// mutator (action + optimistic local twin)
|
|
27
27
|
export const likePost = mutator({
|
|
28
|
-
// Convergent, not incremental: `local` replays on every
|
|
28
|
+
// Convergent, not incremental: `local` replays on every server update, so applying it N times has to
|
|
29
29
|
// equal applying it once — `likedByMe` is what makes the second application a no-op.
|
|
30
30
|
local(tx, { postId }) {
|
|
31
31
|
tx.posts.update(postId, (p) =>
|
|
32
32
|
p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
|
|
33
33
|
},
|
|
34
34
|
async server(ctx, { postId }) { return ctx.posts.like(postId); },
|
|
35
|
-
conflict: 'server-wins', // | 'last-write-wins' | custom(
|
|
35
|
+
conflict: 'server-wins', // | 'last-write-wins' | { kind: 'custom', merge(local, server) }
|
|
36
36
|
});
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
mutation drain are frames in the same discriminated union (`src/sync-protocol.ts`), so the client's
|
|
43
|
-
frame handler is unchanged between rungs.
|
|
39
|
+
A write is always HTTP: `useMutation(likePost)` runs `local` into the page store's optimistic
|
|
40
|
+
overlay, then POSTs the mutator's action with an idempotency key. The socket is **read-only** —
|
|
41
|
+
subscriptions go up, snapshots, patches and presence come down (protocol 3, 21.0.0).
|
|
44
42
|
|
|
45
|
-
`local` must be pure — no I/O, no `Date.now()`, no `Math.random()
|
|
43
|
+
`local` must be pure and convergent — no I/O, no `Date.now()`, no `Math.random()`, and applying it
|
|
44
|
+
over its own result changes nothing — because the overlay REPLAYS it over every server update.
|
|
46
45
|
|
|
47
46
|
## Two entries, and which one an island may import
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
|
|
48
|
+
`@ultimat3/realtime` is the **client** half — the hooks, the page's record store, the offline outbox,
|
|
49
|
+
the wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
|
|
51
50
|
path and the sync node. A name lives in exactly one of them; the `Entry` column below says which.
|
|
52
51
|
|
|
53
52
|
The split is not cosmetic. `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient`
|
|
54
|
-
beside
|
|
53
|
+
beside the client hooks made the browser island this package promises **unbuildable** —
|
|
55
54
|
`Browser build cannot require() Node.js builtin: "stream/web"`. `packages/cli/src/realtime-browser-barrel.test.ts`
|
|
56
|
-
bundles
|
|
55
|
+
bundles a client-only entry for `target: 'browser'` and fails the build if either
|
|
57
56
|
half reaches the other; `barrel-split.test.ts` fails if one name is exported from both.
|
|
58
57
|
|
|
59
58
|
Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
|
|
@@ -63,81 +62,123 @@ Migrating from 7.x: an import of a **server** name changes its specifier and not
|
|
|
63
62
|
+ import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
64
63
|
```
|
|
65
64
|
|
|
66
|
-
Client names —
|
|
67
|
-
|
|
65
|
+
Client names — the hooks, `RecordStore`, `OfflineQueue`, `encode`/`decode`, every `X_*` error
|
|
66
|
+
class — stay on `.`.
|
|
68
67
|
|
|
69
68
|
## Public API
|
|
70
69
|
|
|
71
70
|
| Concern | Entry | Export |
|
|
72
71
|
|---|---|---|
|
|
73
|
-
| tier 1 | `./server` | `
|
|
72
|
+
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
|
|
74
73
|
| tier 2 | `./server` | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
|
|
75
74
|
| replication | `./server` | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
|
|
75
|
+
| the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). Moved from `@ultimat3/testing`, which no longer re-exports it |
|
|
76
76
|
| fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
|
|
77
77
|
| the bus, behind `NatsTransport` | `./server` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
|
|
78
|
-
| reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `
|
|
79
|
-
| the
|
|
80
|
-
|
|
|
81
|
-
| wire | `.` | `PROTOCOL_VERSION
|
|
82
|
-
|
|
|
78
|
+
| reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
|
|
79
|
+
| the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
|
|
80
|
+
| the outbox | `.` | `OfflineQueue`, `MemoryQueueStore` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
|
|
81
|
+
| wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
|
|
82
|
+
| the node | `./server` | `createSyncNode` / `listenSyncNode` (`sync` role) |
|
|
83
83
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
84
|
-
| hooks | `.` | `
|
|
85
|
-
|
|
|
86
|
-
|
|
|
84
|
+
| hooks | `.` | `useQuery`, `useRecord`, `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
|
|
85
|
+
| channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
|
|
86
|
+
| offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `MemoryLocalStore` |
|
|
87
|
+
| the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
|
|
87
88
|
|
|
88
|
-
##
|
|
89
|
+
## `channelRef` — a channel an island can hold
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
**A server render is not a missing registration.** With no DOM there is no socket a client could
|
|
95
|
-
have been registered for, so every hook falls back to `serverRenderLiveClient()`: `useLive` answers
|
|
96
|
-
`state() === 'loading'` with no rows, `useConnection()` reports online, both queue counts are `0`,
|
|
97
|
-
and `mutate` / `drain` refuse with `X_LIVE_SERVER_RENDER`. The page renders its own loading branch
|
|
98
|
-
and a hydrating island takes over. `hasLiveClient()` still answers `false` there, which is what a
|
|
99
|
-
component with a static fallback is asking. `useLive` in a page BODY is not made live by this — a
|
|
100
|
-
page component never runs in a browser; put the live half in an `island()`.
|
|
91
|
+
A channel has two halves, and they live in two files so a browser chunk never bundles an entity or
|
|
92
|
+
a policy. `channelRef(name, { params, catchUp })` is the **client** half: the name, the ordered
|
|
93
|
+
params and the catch-up read — nothing else. The server declares the records and who may join on
|
|
94
|
+
**the same ref** with `channel(ref, { … })`, so the name and the params are written once.
|
|
101
95
|
|
|
102
96
|
```ts
|
|
103
|
-
|
|
97
|
+
// app/posts/channel-ref.ts — imported by islands
|
|
98
|
+
import { channelRef } from '@ultimat3/realtime';
|
|
99
|
+
|
|
100
|
+
export const ORG_POSTS = channelRef('org-posts', {
|
|
101
|
+
params: ['orgId'],
|
|
102
|
+
catchUp: { name: 'orgPosts' }, // the query a client re-runs on `replay-gap` or a new epoch
|
|
103
|
+
});
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const like = useMutation(likePost); // await like(input); like.pending
|
|
108
|
-
const queue = useMutationQueue(); // .pending .failed .drain()
|
|
105
|
+
// The one topic spelling both halves use.
|
|
106
|
+
ORG_POSTS.topic({ orgId: 'org_1' }); // 'org-posts.org_1'
|
|
109
107
|
```
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
| Every member is a **getter**, every result set an **accessor** | a value snapshotted at hook time never re-renders |
|
|
115
|
-
| A thunk `input` is read **once**, at subscribe time | nothing here re-runs it; changing input is a new subscription |
|
|
116
|
-
| The caller owns `unsubscribe` | this layer does not know what a mount is |
|
|
117
|
-
| Every subscription handle (`useLive`'s return, `client.subscribe(topic, …)`'s return) is `Disposable` | `using feed = useLive(liveFeed, () => input)` unsubscribes on scope exit — the same call as `unsubscribe()`, never a second teardown path |
|
|
118
|
-
| `pending` / `failed` are read off the queue, through an invalidation signal refreshed on each `mutate` and `drain` | the count is never a second copy of the queue, and `OfflineQueue` holds arrays, not signals |
|
|
109
|
+
The server half, in its own file, names the entity and the policy on the same ref —
|
|
110
|
+
`channel(ORG_POSTS, { records: [posts], policy: feedRead })` — and an island subscribes with
|
|
111
|
+
`useChannel(ORG_POSTS, { orgId })`.
|
|
119
112
|
|
|
120
|
-
|
|
113
|
+
`ref.topic(params)` is the one spelling of the topic both halves use (`org-posts.<orgId>`), and
|
|
114
|
+
`bun run channel-literals` refuses a hand-built topic anywhere else. A bad name or a repeated param
|
|
115
|
+
is `X_CHANNEL_DECLARATION_INVALID` at declaration. `catchUp` is read on every access, so a query
|
|
116
|
+
whose name `registerQueries()` stamps at boot is picked up. The reference app's
|
|
117
|
+
[`app/posts/channel-ref.ts`](../../examples/dummy/apps/web/app/posts/channel-ref.ts) is the idiom.
|
|
121
118
|
|
|
122
|
-
|
|
119
|
+
## The hooks
|
|
123
120
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
One record store and one socket per **page**, on `globalThis`: every island is its own bundle, so a
|
|
122
|
+
module-level singleton would be one per island. The island bootstrap `x build` prepends installs
|
|
123
|
+
realtime for its bundle — `installRealtime({ signal: createSignal, sync: { url, buildId } })` — and
|
|
124
|
+
an island never constructs a client or a socket. The socket opens on the first live hook; an island
|
|
125
|
+
that only reads records or writes ships none of it.
|
|
128
126
|
|
|
129
127
|
```ts
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
128
|
+
import {
|
|
129
|
+
type ChannelRef,
|
|
130
|
+
type MutatorLike,
|
|
131
|
+
useChannel,
|
|
132
|
+
useConnection,
|
|
133
|
+
useMutation,
|
|
134
|
+
useMutationQueue,
|
|
135
|
+
usePresence,
|
|
136
|
+
useQuery,
|
|
137
|
+
useRecord,
|
|
138
|
+
} from '@ultimat3/realtime';
|
|
139
|
+
|
|
140
|
+
declare const orgId: string;
|
|
141
|
+
declare const postId: string;
|
|
142
|
+
declare const LIKE_POST: MutatorLike; // name + local twin + conflict — never the mutator VALUE
|
|
143
|
+
declare const orgFeed: ChannelRef<'orgId'>; // a `channel('org-feed', { params: ['orgId'], … })`
|
|
144
|
+
declare function onEvent(event: Readonly<Record<string, unknown>>): void;
|
|
145
|
+
|
|
146
|
+
const feed = useQuery({ name: 'liveFeed', live: true }, { orgId }); // AsyncState<readonly Row[]>
|
|
147
|
+
const posts = useQuery({ name: 'listPosts', entity: 'posts' }, {}); // one HTTP read, rows as records
|
|
148
|
+
const post = useRecord('posts', postId); // AsyncState<Row | undefined>
|
|
149
|
+
const like = useMutation(LIKE_POST); // await like(input); like.pending
|
|
150
|
+
const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
|
|
151
|
+
const writes = useMutationQueue(); // .pending .failed
|
|
152
|
+
const feedChannel = useChannel(orgFeed, { orgId }, { onEvent }); // records → the store; events → onEvent
|
|
153
|
+
const room = usePresence(orgFeed, { orgId }); // the channel's roster
|
|
134
154
|
```
|
|
135
155
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
`
|
|
156
|
+
**One socket per origin and principal**: the page's socket lives in a `SharedWorker`
|
|
157
|
+
(`@ultimat3/realtime/sync-worker`, bundled by `x build`) shared by every tab; with no worker the
|
|
158
|
+
same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
|
|
159
|
+
replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
|
|
160
|
+
the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
|
|
161
|
+
CLI renders no boot for (no scope tag, so nothing on it persists) such a write is refused like any
|
|
162
|
+
other — rejected, overlay taken back — never held in memory a reload would silently lose.
|
|
163
|
+
|
|
164
|
+
`<AsyncRegion state={feed()} …/>` takes the answer as-is: `AsyncState` is `@ultimat3/core`'s, the
|
|
165
|
+
same type `@ultimat3/ui` renders.
|
|
166
|
+
|
|
167
|
+
| Rule | Why |
|
|
168
|
+
|---|---|
|
|
169
|
+
| A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
|
|
170
|
+
| Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
|
|
171
|
+
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
|
|
172
|
+
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
|
|
173
|
+
| **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
|
|
174
|
+
| Every member is a **getter**, every result an **accessor** | a value snapshotted at hook time never re-renders |
|
|
175
|
+
| `input` is read **once** | nothing here re-runs it; a changed input is a new `useQuery` |
|
|
176
|
+
| The caller owns `release()` / `using` | this layer does not know what a mount is |
|
|
177
|
+
|
|
178
|
+
**A server render** (no install, no DOM) answers `pending`, reports online and creates no page
|
|
179
|
+
state; a `useMutation` call refuses with `X_LIVE_SERVER_RENDER`. With a DOM and no install, every
|
|
180
|
+
hook is `X_REALTIME_UNINSTALLED`. `hasPageSocket()` is the guard a component with a static fallback
|
|
181
|
+
asks.
|
|
141
182
|
|
|
142
183
|
## Who a socket is
|
|
143
184
|
|
|
@@ -147,16 +188,25 @@ resolves is what every policy downstream decides against — the topic guard, `a
|
|
|
147
188
|
the per-tenant subscription cap.
|
|
148
189
|
|
|
149
190
|
```ts
|
|
150
|
-
|
|
191
|
+
import type { Actor } from '@ultimat3/core';
|
|
192
|
+
import type { SyncGrant, SyncNodeOptions } from '@ultimat3/realtime/server';
|
|
193
|
+
|
|
194
|
+
declare function sessionFrom(
|
|
195
|
+
request: Request,
|
|
196
|
+
): Promise<{ actor: Actor; expiresAt: number; token: string } | null>;
|
|
197
|
+
declare function renew(token: string): Promise<SyncGrant | null>;
|
|
198
|
+
|
|
199
|
+
// The one option this section is about; `createSyncNode({ …, authenticate })` takes it.
|
|
200
|
+
const options: Pick<SyncNodeOptions, 'authenticate'> = {
|
|
151
201
|
// From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
|
|
152
|
-
//
|
|
202
|
+
// business logic. `refresh` is yours too, so the framework retains no credential of its own.
|
|
153
203
|
authenticate: async (request) => {
|
|
154
204
|
const session = await sessionFrom(request);
|
|
155
205
|
return session === null
|
|
156
206
|
? null
|
|
157
207
|
: { actor: session.actor, expiresAt: session.expiresAt, refresh: () => renew(session.token) };
|
|
158
208
|
},
|
|
159
|
-
}
|
|
209
|
+
};
|
|
160
210
|
```
|
|
161
211
|
|
|
162
212
|
| The answer | What the node does |
|
|
@@ -226,37 +276,22 @@ authenticated socket is the cheapest foothold there is.
|
|
|
226
276
|
`input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
|
|
227
277
|
process rather than a slow query.
|
|
228
278
|
|
|
229
|
-
## One
|
|
279
|
+
## One record per `type:key`, per page
|
|
230
280
|
|
|
231
|
-
Two
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
the same map. A write through any of them is the same row for all of them.
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
|
|
238
|
-
const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
|
|
239
|
-
|
|
240
|
-
await like({ postId: 'p7' }); // one optimistic write...
|
|
241
|
-
feed()[2] === pinned()[0]; // ...and both views are looking at it
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Nothing is declared to get this. There is no normalization schema, no cache key, no selector — an
|
|
245
|
-
app writes `useLive` and `useMutation` exactly as before.
|
|
281
|
+
Two islands showing post #7 hold **one** record, not two copies. The page's `RecordStore` is the
|
|
282
|
+
whole client store: every live window, every `useQuery` list and every `useRecord` is a projection
|
|
283
|
+
over it, and an HTTP answer's records, a socket patch and an optimistic write all land in it.
|
|
246
284
|
|
|
247
285
|
| Rule | Why |
|
|
248
286
|
|---|---|
|
|
249
|
-
| Identity is `
|
|
250
|
-
| The
|
|
251
|
-
|
|
|
252
|
-
|
|
|
253
|
-
| A write **merges** columns
|
|
254
|
-
| A row is dropped
|
|
255
|
-
|
|
|
256
|
-
|
|
257
|
-
`entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
|
|
258
|
-
the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
|
|
259
|
-
both directions, which is why it carries no `PROTOCOL_VERSION` bump.
|
|
287
|
+
| Identity is `type:key` — the entity's NAME and the key the SERVER computed | two entities may spell one key the same way, and the browser has no entity schema to derive a key from |
|
|
288
|
+
| The live path names the type server-side (`recordTypeForTable`) | a changefeed speaks tables; the store speaks entities |
|
|
289
|
+
| Two layers: synced truth and the optimistic overlay | a refused write drops its overlay and shows exactly what the server said — never a stale before-image |
|
|
290
|
+
| The overlay is REPLAYED over every server update | two pending writes on one row land in order on top of someone else's change |
|
|
291
|
+
| A value is **replaced, never mutated**; a write **merges** columns | a mutated row is a render that never happens; a narrower projection must not blank a wider one |
|
|
292
|
+
| A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
|
|
293
|
+
| The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
|
|
294
|
+
| A principal change clears the store | nothing of the previous principal survives in memory |
|
|
260
295
|
|
|
261
296
|
## Reconnect is the hard part
|
|
262
297
|
|
|
@@ -284,7 +319,7 @@ sends a `reconnect` frame carrying that delay — clients redistribute instead o
|
|
|
284
319
|
because refusing without one just moves the herd next door.
|
|
285
320
|
|
|
286
321
|
The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
|
|
287
|
-
frame assigned one, otherwise `backoffDelay()` — and that timer calls `connect()`, which re-subscribes
|
|
322
|
+
frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `BackoffPolicy` — and that timer calls `connect()`, which re-subscribes
|
|
288
323
|
every registration **and re-announces every topic**. Topic membership is state on the node's socket
|
|
289
324
|
and `hello` carries none of it, so without that half a channel goes silent from the first reconnect
|
|
290
325
|
onwards while its handler is still installed — and its presence membership is swept, because
|
|
@@ -292,14 +327,18 @@ subscribing to a topic *is* joining the room. `reconnectAt` is what a component
|
|
|
292
327
|
waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
|
|
293
328
|
`Scheduler`, so a test fires it by hand instead of sleeping.
|
|
294
329
|
|
|
330
|
+
A browser's curve is `browserBackoff` — the same core curve, `equal` jitter, capped at
|
|
331
|
+
`BROWSER_RECONNECT_MAX_MS` (4s) where the server-side `defaultBackoff` caps at 30s: a node that
|
|
332
|
+
comes back is reached within seconds, and so is the `update-available` it carries. The socket
|
|
333
|
+
engine (one per origin, in the `SharedWorker`) keeps none of it across pages: a page arriving while
|
|
334
|
+
the node is down dials at once on a fresh curve, and the last page leaving forgets the target, so
|
|
335
|
+
the next build's page never dials with the old build id.
|
|
336
|
+
|
|
295
337
|
### Liveness: `heartbeatMs`
|
|
296
338
|
|
|
297
339
|
A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
|
|
298
|
-
browser. The client is the only thing that can end one
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
|
|
302
|
-
```
|
|
340
|
+
browser. The client is the only thing that can end one, and the page socket beats at the default
|
|
341
|
+
below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
|
|
303
342
|
|
|
304
343
|
| Property | Behaviour |
|
|
305
344
|
|---|---|
|
|
@@ -329,7 +368,8 @@ wire twice by a reconnect that raced an ack.
|
|
|
329
368
|
| Backpressure **declines**, it does not fail | over `MAX_BUFFERED_BYTES` (1 MiB, the node's `backpressureLimit` at the other end of the same socket) the sender throws `X_TRANSPORT_UNAVAILABLE`, the mutation stays pending and the next drain resumes there. `ClientSocket.bufferedAmount` is optional; a socket that does not report it is treated as never backed up |
|
|
330
369
|
| Delivery is therefore at least once | every mutation carries an idempotency key — the `key` argument, or `<mutator>:<uuid>` — and the resend carries the same one |
|
|
331
370
|
| A lost connection **cancels the pass it interrupted** | the lane orders passes against each other, but a socket death is not a pass and cannot reach one parked inside `send`. `requeueInflight()` bumps a connection epoch; a pass whose epoch went stale returns and leaves the rest `pending`. Without it the parked pass resumed and marked everything behind it `inflight` for a dead socket — never re-sent (`inflight` is not sendable) and never acked |
|
|
332
|
-
| The store is handed
|
|
371
|
+
| The store is handed **snapshots, by key**, never the live entries or the whole queue | `QueueStore.write` is a durable write and may await before it reads; given an entry itself it persists a status that was never true when it was called. By key, because two tabs of one user share the store and a whole-queue save let the last tab erase the other's write |
|
|
372
|
+
| One tab drains at a time, and drains what every tab queued | the outbox's replay runs under the Web Lock `ultimate-outbox:<principal>` and re-reads the queue first, so a write another tab queued is sent, in order, and a stored `inflight` from a closed page goes back to `pending` |
|
|
333
373
|
|
|
334
374
|
### Limits, stated plainly
|
|
335
375
|
|
|
@@ -353,16 +393,9 @@ wire twice by a reconnect that raced an ack.
|
|
|
353
393
|
froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
|
|
354
394
|
client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
|
|
355
395
|
exactly during the deploy storm it was built for.
|
|
356
|
-
- **
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
|
|
360
|
-
- **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
|
|
361
|
-
mutation's optimistic write *and* every write made after it — newest first — then replays the
|
|
362
|
-
others without it, which is sound only because `local` is pure. The refused intent is dropped from
|
|
363
|
-
the rebase log rather than retried: a denial is a decision about that intent, and replaying it
|
|
364
|
-
would put the write the server refused back on the screen. Idempotent for a key the log does not
|
|
365
|
-
hold, because a denial can arrive twice and tier 2 records nothing to undo.
|
|
396
|
+
- **The socket carries no writes (protocol 3, 21.0.0).** A write is HTTP; an `ack` is only ever a
|
|
397
|
+
refusal, naming the subscription it refused (that window renders `failed`) or the socket for a
|
|
398
|
+
frame the node could not read — including a `mutate` from a client one major behind.
|
|
366
399
|
- **Nothing on the client detects drift, and nothing ever did.** `verifyDigest()` claimed to and had
|
|
367
400
|
no caller (deleted 2026-08-23); the `digest` it read went with it (2026-08-24), along with the
|
|
368
401
|
`count` beside it. What detects drift is the server's `desynced` mark and the re-snapshot it
|
|
@@ -388,10 +421,10 @@ wire twice by a reconnect that raced an ack.
|
|
|
388
421
|
cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
|
|
389
422
|
`WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
|
|
390
423
|
tracked app implements it, so deleting the members breaks that app's typecheck.
|
|
391
|
-
- **Inbound frames are ordered per
|
|
424
|
+
- **Inbound frames are ordered per subscription, never per socket.** A
|
|
392
425
|
global per-socket lane puts every frame behind the slowest one, and the slowest one is a
|
|
393
426
|
subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
|
|
394
|
-
`
|
|
427
|
+
`subscribe` is one lane per sid, or per topic name; `hello` and
|
|
395
428
|
the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
|
|
396
429
|
lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
|
|
397
430
|
- **`qid` is `@ultimat3/query`'s `queryHash(name, input)`** — `<name>:<first 16 hex of
|
|
@@ -490,11 +523,15 @@ wire twice by a reconnect that raced an ack.
|
|
|
490
523
|
pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
|
|
491
524
|
crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
|
|
492
525
|
`InMemoryAdvisoryLock` remains the single-process default for `x dev` and tests.
|
|
493
|
-
- **`selectTransport(env)` decides which transport a boot fans out on** —
|
|
494
|
-
the
|
|
495
|
-
|
|
496
|
-
`
|
|
497
|
-
|
|
526
|
+
- **`selectTransport(env, realtime)` decides which transport a boot fans out on** — and since
|
|
527
|
+
22.0.0 the CONFIG decides it, not the environment: `realtime` is `app.config.ts`'s
|
|
528
|
+
`{ transport, urlEnv }`. It returns `{ transport, mode, detail, bucket, presenceTtlMs, connect }`:
|
|
529
|
+
`'memory'` → `InProcessTransport` and `mode: 'embedded'`; `'nats'` → a `NatsTransport` dialling
|
|
530
|
+
the variable `urlEnv` names, on the KV bucket `NATS_KV_BUCKET` names (default `x_presence`, so
|
|
531
|
+
two apps on one cluster do not share one presence namespace), validated here rather than on
|
|
532
|
+
first connect. Both mismatches refuse with `X_CONFIG_INVALID`: `'nats'` with that variable unset,
|
|
533
|
+
and `'memory'` with `NATS_URL` (or the named variable) set — an operator who set one expected
|
|
534
|
+
fanout across nodes. Until 22.0.0 `NATS_URL` alone decided and both keys were read by nothing.
|
|
498
535
|
`presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
|
|
499
536
|
it — a `PresenceRegistry` given a different number would report members leaving that never left.
|
|
500
537
|
Selection is pure; `connect()` is the dial, so an unreachable bus fails at boot.
|
|
@@ -528,16 +565,9 @@ wire twice by a reconnect that raced an ack.
|
|
|
528
565
|
running half: one per change delivered off a relation that is not FULL, so the decisions it
|
|
529
566
|
actually cost are countable rather than silent. A hard refusal at `x verify` time is the
|
|
530
567
|
follow-up.
|
|
531
|
-
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
subpath `exports` never declared). `MemoryLocalStore`, beside it on `.`, implements the full
|
|
535
|
-
journal/rollback/replay semantics today and is what the refusal's `fix:` names. It holds
|
|
536
|
-
membership and the journal; the row values are the client's one `IdentityMap`, which is what a
|
|
537
|
-
browser store has to inherit rather than re-implement.
|
|
538
|
-
- The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
|
|
539
|
-
row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
|
|
540
|
-
exactly as long as a window or a table holds it.
|
|
568
|
+
- The record store is **per page**, in memory, and it is not a query cache: it answers "what is
|
|
569
|
+
record X now", never "have I run this query before". Nothing evicts by time or size — a record
|
|
570
|
+
lives as long as something holds it. Persisting it (IndexedDB) is plan 101 slice 12.
|
|
541
571
|
|
|
542
572
|
## Errors
|
|
543
573
|
|
|
@@ -545,7 +575,8 @@ wire twice by a reconnect that raced an ack.
|
|
|
545
575
|
`X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
|
|
546
576
|
`X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
|
|
547
577
|
`X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
|
|
548
|
-
`
|
|
578
|
+
`X_REALTIME_UNINSTALLED` · `X_SYNC_UNCONFIGURED` · `X_RECORD_REJECTED` ·
|
|
579
|
+
`X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
|
|
549
580
|
`X_LIVE_REPLICA_IDENTITY` ·
|
|
550
581
|
`X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
|
|
551
582
|
`X_TIMEOUT`
|
|
@@ -553,8 +584,9 @@ wire twice by a reconnect that raced an ack.
|
|
|
553
584
|
`X_NOT_IMPLEMENTED` and `X_TIMEOUT` are **borrowed** from `@ultimat3/core`, which owns and titles
|
|
554
585
|
them — `REALTIME_BORROWED_ERROR_CODES`. Everything else on that list is realtime's own.
|
|
555
586
|
|
|
556
|
-
Topics deny by default: a topic
|
|
557
|
-
|
|
587
|
+
Topics deny by default: a topic no `channel()` declares is forbidden, and a `channel()` with no
|
|
588
|
+
`policy` is refused at declaration (`X_CHANNEL_DECLARATION_INVALID`) — a public channel writes
|
|
589
|
+
`policy: allow('public')`. An authz hole is not a config option someone forgot to set.
|
|
558
590
|
|
|
559
591
|
An upgrade `authenticate` refuses is `X_SOCKET_UNAUTHENTICATED` (401) and one it *could not decide*
|
|
560
592
|
is `X_SOCKET_AUTH_UNAVAILABLE` (503). Two codes, because the two have opposite instructions: the
|
|
@@ -573,3 +605,32 @@ The fix is `x queries list --json`, and the name the client sent is echoed back
|
|
|
573
605
|
never is.
|
|
574
606
|
|
|
575
607
|
`As of 2026-07`: tiers 1–2 target v1, tier 3 targets v2.
|
|
608
|
+
|
|
609
|
+
### Error classes
|
|
610
|
+
|
|
611
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
612
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
613
|
+
|
|
614
|
+
| Class | Code | Declared in |
|
|
615
|
+
|---|---|---|
|
|
616
|
+
| `CursorStaleError` (extends `RealtimeError`) | `X_CURSOR_STALE` | `src/page-errors.ts` |
|
|
617
|
+
| `FrameRateLimitError` (extends `RealtimeError`) | `X_FRAME_RATE_LIMIT` | `src/errors.ts` |
|
|
618
|
+
| `LiveQueryUnknownError` (extends `RealtimeError`) | `X_LIVE_QUERY_UNKNOWN` | `src/errors.ts` |
|
|
619
|
+
| `LiveRowUnidentifiedError` (extends `RealtimeError`) | `X_LIVE_ROW_UNIDENTIFIED` | `src/errors.ts` |
|
|
620
|
+
| `NotImplementedError` (extends `RealtimeError`) | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
621
|
+
| `ProtocolVersionError` (extends `RealtimeError`) | `X_PROTOCOL_VERSION` | `src/page-errors.ts` |
|
|
622
|
+
| `RealtimeError` | any `RealtimeErrorCode` — `REALTIME_ERROR_CODES`; the base of every other class here, thrown directly for a code none of them covers | `src/realtime-error.ts` |
|
|
623
|
+
| `RealtimeUninstalledError` (extends `RealtimeError`) | `X_REALTIME_UNINSTALLED` | `src/page-errors.ts` |
|
|
624
|
+
| `RebaseConflictError` (extends `RealtimeError`) | `X_REBASE_CONFLICT` | `src/page-errors.ts` |
|
|
625
|
+
| `RecordRejectedError` (extends `RealtimeError`) | `X_RECORD_REJECTED` | `src/page-errors.ts` |
|
|
626
|
+
| `ReplicaIdentityError` (extends `RealtimeError`) | `X_LIVE_REPLICA_IDENTITY` | `src/replication-errors.ts` |
|
|
627
|
+
| `ReplicationFailedError` (extends `RealtimeError`) | `X_REPLICATION_FAILED` | `src/replication-errors.ts` |
|
|
628
|
+
| `ReplicationProtocolError` (extends `RealtimeError`) | `X_REPLICATION_PROTOCOL` | `src/replication-errors.ts` |
|
|
629
|
+
| `ReplicatorSlotHeldError` (extends `RealtimeError`) | `X_REPLICATOR_SLOT_HELD` | `src/replication-errors.ts` |
|
|
630
|
+
| `ServerRenderLiveError` (extends `RealtimeError`) | `X_LIVE_SERVER_RENDER` | `src/page-errors.ts` |
|
|
631
|
+
| `SubscriptionLimitError` (extends `RealtimeError`) | `X_SUBSCRIPTION_LIMIT` | `src/errors.ts` |
|
|
632
|
+
| `SyncUnconfiguredError` (extends `RealtimeError`) | `X_SYNC_UNCONFIGURED` | `src/page-errors.ts` |
|
|
633
|
+
| `TopicForbiddenError` (extends `RealtimeError`) | `X_TOPIC_FORBIDDEN` | `src/errors.ts` |
|
|
634
|
+
| `TransportProtocolError` (extends `RealtimeError`) | `X_TRANSPORT_PROTOCOL` | `src/errors.ts` |
|
|
635
|
+
| `TransportUnavailableError` (extends `RealtimeError`) | `X_TRANSPORT_UNAVAILABLE` | `src/errors.ts` |
|
|
636
|
+
| `WindowReadTimeoutError` (extends `RealtimeError`) | `X_TIMEOUT` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./src/index.ts",
|
|
21
|
-
"./
|
|
21
|
+
"./boot": "./src/boot.ts",
|
|
22
|
+
"./server": "./src/server.ts",
|
|
23
|
+
"./sync-worker": "./src/sync-worker.ts"
|
|
22
24
|
},
|
|
23
25
|
"files": [
|
|
24
26
|
"src",
|
|
@@ -36,8 +38,9 @@
|
|
|
36
38
|
"test": "bun test"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/
|
|
41
|
+
"@ultimat3/core": "22.0.0",
|
|
42
|
+
"@ultimat3/entity": "22.0.0",
|
|
43
|
+
"@ultimat3/query": "22.0.0",
|
|
41
44
|
"nats": "2.29.3"
|
|
42
45
|
}
|
|
43
46
|
}
|
package/src/apply-patches.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// app can reuse to apply the same patches to its own store.
|
|
4
4
|
//
|
|
5
5
|
// Order and values are folded separately: a live window keeps its ORDER here and its VALUES in the
|
|
6
|
-
//
|
|
6
|
+
// record store, so `applyPatches` is the array form of the same fold rather than a second one.
|
|
7
7
|
|
|
8
8
|
import type { JsonObject, Row, RowPatch } from './json';
|
|
9
9
|
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `@ultimat3/realtime/boot` — the page's boot, ONE classic script per document (plan 101), built
|
|
2
|
+
// and served by the CLI the way the sync worker is. It restores the principal's persisted records
|
|
3
|
+
// and opens the outbox, so a reload replays what the previous load queued even on a page whose
|
|
4
|
+
// islands only read. Here and not in `page-store.ts`, so no island bundle carries it.
|
|
5
|
+
|
|
6
|
+
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
+
import { pageClient } from '@ultimat3/core/page';
|
|
8
|
+
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
9
|
+
import { pageOutbox } from './page-outbox';
|
|
10
|
+
import { BOOT_KEY, BOOT_RELEASE_KEY, type BootHost, pageRealtime } from './page-store';
|
|
11
|
+
import { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
|
|
12
|
+
import type { RecordStore } from './record-store';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The types the document marks persisted, read back off disk, then the page's outbox opened. None
|
|
16
|
+
* marked is no record disk.
|
|
17
|
+
*/
|
|
18
|
+
async function restoreFromDisk(store: RecordStore, principal: Principal): Promise<void> {
|
|
19
|
+
const types = persistedTypes();
|
|
20
|
+
const keep = scopeKey(principal);
|
|
21
|
+
let persister: RecordPersister | undefined;
|
|
22
|
+
try {
|
|
23
|
+
// A sign-out by full navigation (a form post and a redirect) never calls `rescope()`, so the
|
|
24
|
+
// previous principal's rows and queued writes would outlive it on disk. The boot is the one
|
|
25
|
+
// moment every page load passes: everything not THIS principal's goes, before anything is
|
|
26
|
+
// restored. An unscoped page (rendered for nobody) wipes nothing — it knows no principal.
|
|
27
|
+
// Trade-off: two principals in two tabs of one browser — the newer boot wipes the other's
|
|
28
|
+
// disk; that tab keeps its records in memory and re-persists them on its next write.
|
|
29
|
+
if (keep !== undefined) await (await pageLocalStore()).wipeOthers(keep);
|
|
30
|
+
if (types.size > 0) {
|
|
31
|
+
persister = recordPersister({ store, local: await pageLocalStore(), types });
|
|
32
|
+
await persister.restore();
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// A disk the browser refused (quota, a private window) costs the offline copy, never the page.
|
|
36
|
+
console.error(error);
|
|
37
|
+
}
|
|
38
|
+
// The outbox opens with the page, not with the first write or live hook: a reload that holds
|
|
39
|
+
// neither must still replay what the previous load queued (on open, `online`, the SW's drain).
|
|
40
|
+
// After the restore, so a replay settles overlays over the records it restored.
|
|
41
|
+
// A queued write flushes the persisted rows first, so both are on disk before a reload can come.
|
|
42
|
+
const kept = persister;
|
|
43
|
+
pageOutbox({ beforeEnqueue: kept === undefined ? undefined : () => kept.flush() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Principal = ClientScope['principal'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Once per page, whoever calls first; the promise is what `pageRealtime().booted` answers. The
|
|
50
|
+
* scope is the page's own — a parameter only so a test can boot an unscoped page (an OBJECT, so an
|
|
51
|
+
* explicit `undefined` principal is not mistaken for "use the default").
|
|
52
|
+
*/
|
|
53
|
+
export function bootPage(
|
|
54
|
+
scope: { readonly principal: Principal } = pageClient().scope,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const host = globalThis as BootHost;
|
|
57
|
+
const started = host[BOOT_KEY];
|
|
58
|
+
const waiting = host[BOOT_RELEASE_KEY];
|
|
59
|
+
// Already booting — unless what sits there is an island's placeholder, which this boot claims.
|
|
60
|
+
if (started !== undefined && waiting === undefined) return started;
|
|
61
|
+
Reflect.deleteProperty(host, BOOT_RELEASE_KEY);
|
|
62
|
+
const booted = restoreFromDisk(pageRealtime().store, scope.principal);
|
|
63
|
+
if (started !== undefined && waiting !== undefined) {
|
|
64
|
+
void booted.then(waiting);
|
|
65
|
+
return started;
|
|
66
|
+
}
|
|
67
|
+
Object.defineProperty(host, BOOT_KEY, { value: booted, configurable: true });
|
|
68
|
+
return booted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// As a page script it runs itself; imported by a test (no DOM) it waits to be called.
|
|
72
|
+
if (typeof document !== 'undefined') void bootPage();
|