@ultimat3/realtime 23.0.0 → 25.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.
Files changed (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -1,13 +1,10 @@
1
1
  /**
2
- * The page's ONE outbox: writes a page could not send are queued in `OfflineQueue`, persisted in
3
- * the page's durable store under the current principal, and replayed IN ORDER over HTTP — each to
4
- * `actionPath(name)` through core's `clientTransport`, each with its own idempotency key, so a
5
- * replay after a lost response is answered from the action's idempotency store and never applied
6
- * twice. Replayed on open, on `online`, and on the service worker's `OUTBOX_DRAIN_MESSAGE`.
7
- * A principal change wipes the previous principal's queue: its writes are never sent as the next.
2
+ * The page's ONE outbox: writes a page could not send, persisted under the current principal and
3
+ * replayed IN ORDER over HTTP, each under its own idempotency key. AT LEAST once — an entry leaves
4
+ * the disk when its ack is written. A principal change abandons the pass and wipes the queue.
8
5
  */
9
6
 
10
- import type { ClientScope } from '@ultimat3/core/page';
7
+ import type { ClientScope, UltimateError } from '@ultimat3/core/page';
11
8
  import {
12
9
  actionPath,
13
10
  classifyThrown,
@@ -15,12 +12,14 @@ import {
15
12
  OUTBOX_DRAIN_MESSAGE,
16
13
  onRescope,
17
14
  pageClient,
15
+ renderThrowable,
18
16
  } from '@ultimat3/core/page';
19
17
  import type { LocalStore } from './local-store-idb';
20
18
  import { pageLocalStore, scopeKey } from './local-store-idb';
21
19
  import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
22
- import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
20
+ import { memoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
23
21
  import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
22
+ import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
24
23
  import { peekPageRealtime } from './page-store';
25
24
  import { carriedBy } from './record-store';
26
25
 
@@ -63,70 +62,142 @@ export interface OutboxOptions {
63
62
  * the write queued and the post it liked missing, and showed the old count.
64
63
  */
65
64
  readonly beforeEnqueue?: (() => Promise<void>) | undefined;
65
+ /** Where a disk that refused the queue is said, by code. Default `console.warn`. */
66
+ readonly warn?: ((error: UltimateError) => void) | undefined;
66
67
  }
67
68
 
68
69
  const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
69
70
 
70
- export function createOutbox(options: OutboxOptions): PageOutbox {
71
+ /** One principal's open queue, and the scope the drain lock is named after. */
72
+ interface Held {
73
+ readonly queue: OfflineQueue;
74
+ readonly scope: string | undefined;
75
+ }
76
+
77
+ export function localOutbox(options: OutboxOptions): PageOutbox {
71
78
  const principal =
72
79
  options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
73
80
  const send = options.send ?? sendOverHttp;
74
81
  const overlays =
75
82
  options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
76
- let queue: OfflineQueue | undefined;
77
- /** The scope the open queue belongs to — what the drain lock is named after. */
78
- let scope: string | undefined;
83
+ /**
84
+ * The open queue AND the scope it belongs to, as one value: a pass captures both when it starts,
85
+ * so nothing it does later can be read off a principal that arrived since.
86
+ */
87
+ let held: Held | undefined;
88
+ /**
89
+ * Bumped by every principal change, SYNCHRONOUSLY. Anything here that resolves a queue after an
90
+ * await compares it first: the answer to "whose queue" must be the one from before the await.
91
+ */
92
+ let epoch = 0;
93
+ const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
94
+ const unavailable = (what: string, error: unknown): void =>
95
+ warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
79
96
 
97
+ /** Never rejects: a queue the disk would not open is a memory queue, said once by code. */
80
98
  const open = async (): Promise<void> => {
81
- scope = scopeKey(principal());
82
- queue = await OfflineQueue.open(queueStore(await options.local, scope));
83
- };
84
- let ready = open();
85
- let running: Promise<DrainReport> | undefined;
86
- /** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
87
- let again = false;
88
-
89
- const deliver = async (mutation: QueuedMutation): Promise<void> => {
90
- const current = queue;
91
- const carried = new Set<string>();
99
+ const at = epoch;
100
+ const scope = scopeKey(principal());
101
+ let queue: OfflineQueue;
92
102
  try {
93
- await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
103
+ queue = await OfflineQueue.open(queueStore(await options.local, scope));
94
104
  } catch (error) {
95
- const kind = classifyThrown(error);
96
- // The network, or a server asking to be asked again: stays queued, and the pass stops so
97
- // nothing behind it overtakes it.
98
- if (kind === 'retryable' || kind === 'retry-after') throw error;
99
- // Anything else is the server's decision about this write — kept for the UI, never resent.
100
- await current?.fail(mutation.key, toQueueError(error));
101
- overlays()?.drop(mutation.key);
105
+ unavailable('the outbox could not be opened', error);
106
+ queue = await OfflineQueue.open(memoryQueueStore());
107
+ }
108
+ // The principal left while the disk was being read: this queue is the one that left with it,
109
+ // and the handler that saw the change has already chained the next open.
110
+ if (at !== epoch) {
111
+ queue.abandon();
102
112
  return;
103
113
  }
104
- await current?.ack(mutation.key);
105
- const store = overlays();
106
- try {
107
- // Exactly as a live write settles: a row the answer did not carry keeps its overlay until
108
- // the server's row for it arrives.
109
- store?.settle(mutation.key, carried);
110
- } catch {
111
- // A custom merge that answered no row: the server's truth stands.
112
- store?.drop(mutation.key);
114
+ held?.queue.abandon();
115
+ held = { queue, scope };
116
+ };
117
+ let ready = open();
118
+ /** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
119
+ const current = async (): Promise<Held | undefined> => {
120
+ for (;;) {
121
+ const opening = ready;
122
+ await opening;
123
+ if (opening === ready) return held;
113
124
  }
114
125
  };
126
+ let running: Promise<DrainReport> | undefined;
127
+ /** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
128
+ let again = false;
129
+
130
+ const deliver =
131
+ (queue: OfflineQueue) =>
132
+ async (mutation: QueuedMutation): Promise<void> => {
133
+ const carried = new Set<string>();
134
+ try {
135
+ await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
136
+ } catch (error) {
137
+ const kind = classifyThrown(error);
138
+ // The network, or a server asking to be asked again: stays queued, and the pass stops so
139
+ // nothing behind it overtakes it. So does a queue whose principal left mid-send.
140
+ if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
141
+ // Anything else is the server's decision about this write — kept for the UI, never resent.
142
+ await queue.fail(mutation.key, toQueueError(error));
143
+ overlays()?.drop(mutation.key);
144
+ return;
145
+ }
146
+ // The principal changed while this was on the wire: its store and its twins are already
147
+ // gone, and what is on the page now is somebody else's.
148
+ if (queue.abandoned) return;
149
+ await queue.ack(mutation.key);
150
+ const store = overlays();
151
+ try {
152
+ // Exactly as a live write settles: a row the answer did not carry keeps its overlay until
153
+ // the server's row for it arrives.
154
+ store?.settle(mutation.key, carried);
155
+ } catch {
156
+ // A custom merge that answered no row: the server's truth stands.
157
+ store?.drop(mutation.key);
158
+ }
159
+ };
115
160
 
116
161
  onRescope((_next, prev) => {
117
162
  const gone = scopeKey(prev.principal);
163
+ // SYNCHRONOUSLY, before anything is awaited: a pass parked inside `send` resumes into the next
164
+ // principal's session, and the wipe below reaches the disk but never the pass. Abandoned, it
165
+ // sends nothing more and writes nothing back.
166
+ held?.queue.abandon();
167
+ held = undefined;
168
+ epoch += 1;
169
+ // The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
170
+ // the abandoned pass — handed its report, another principal's key in it — and its own queue
171
+ // was not drained until some later trigger. `settled` only clears a slot that is still its own.
172
+ running = undefined;
173
+ again = false;
118
174
  ready = ready.then(async () => {
119
- if (gone !== undefined) await (await options.local).wipe(gone);
175
+ try {
176
+ if (gone !== undefined) await (await options.local).wipe(gone);
177
+ } catch (error) {
178
+ // Still on disk, under the scope that left: unreachable from this principal, and the next
179
+ // boot's `wipeOthers` takes it. Said, never thrown — a rejection here used to poison the
180
+ // chain, so every later enqueue, replay and rescope on this tab failed with it.
181
+ unavailable("the previous principal's outbox could not be wiped", error);
182
+ }
120
183
  await open();
121
184
  });
122
185
  });
123
186
 
124
187
  const self: PageOutbox = {
125
188
  enqueue: async (entry) => {
189
+ // WHOSE queue is decided now, before anything is awaited: resolved after the flush below,
190
+ // a write issued under one principal and parked there was queued — and sent — as the next.
191
+ // A queue still opening is waited for, and belongs to this call only if nobody left since.
192
+ const at = epoch;
193
+ const mine = held ?? (await current());
194
+ if (mine === undefined || at !== epoch) {
195
+ throw new OfflineQueueAbandonedError({ name: entry.name });
196
+ }
126
197
  // A disk that refused the rows must not also cost the write: the intent still goes on disk.
127
198
  await options.beforeEnqueue?.().catch(() => undefined);
128
- await ready;
129
- await queue?.enqueue(entry);
199
+ // Abandoned meanwhile, it refuses by the same code.
200
+ await mine.queue.enqueue(entry);
130
201
  },
131
202
  replay: () => {
132
203
  // Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
@@ -140,20 +211,19 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
140
211
  return running;
141
212
  }
142
213
  const pass = (async (): Promise<DrainReport> => {
143
- await ready;
144
- if (queue === undefined) return EMPTY;
214
+ const now = await current();
215
+ if (now === undefined) return EMPTY;
145
216
  // Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
146
217
  // attempt the browser already knows cannot leave is a failed request on the wire and
147
218
  // nothing more. `online` asks again.
148
- if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
149
- const draining = queue;
219
+ if (knownOffline()) return { ...EMPTY, remaining: now.queue.pending().length };
150
220
  // One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
151
221
  // queue is re-read under the lock, so a write another tab made since this one opened is
152
222
  // sent too, and an `inflight` entry — which only a pass holding this lock could have put
153
223
  // on the wire, and it is over — goes back to `pending`.
154
- return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
155
- await draining.reload();
156
- return await draining.drain(deliver);
224
+ return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
225
+ await now.queue.reload();
226
+ return await now.queue.drain(deliver(now.queue));
157
227
  });
158
228
  })();
159
229
  const settled = (report?: DrainReport): void => {
@@ -172,9 +242,10 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
172
242
  return pass;
173
243
  },
174
244
  get size(): number {
175
- return queue?.pending().length ?? 0;
245
+ return held?.queue.pending().length ?? 0;
176
246
  },
177
- pending: () => (queue?.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
247
+ pending: () =>
248
+ (held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
178
249
  get ready(): Promise<void> {
179
250
  return ready;
180
251
  },
@@ -184,7 +255,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
184
255
 
185
256
  /** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
186
257
  function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
187
- if (scope === undefined) return new MemoryQueueStore();
258
+ if (scope === undefined) return memoryQueueStore();
188
259
  return {
189
260
  load: async (): Promise<QueueState> =>
190
261
  (await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
@@ -255,7 +326,7 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
255
326
  const existing = host[OUTBOX_KEY];
256
327
  // Only this function writes the slot, so what it holds is always a `PageOutbox`.
257
328
  if (existing !== undefined) return existing as PageOutbox;
258
- const outbox = createOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
329
+ const outbox = localOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
259
330
  Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
260
331
  if (Reflect.has(globalThis, 'document')) {
261
332
  listenForDrain(outbox);
@@ -0,0 +1,72 @@
1
+ // What a realtime island waits on before its first hook runs: the page runtime, installed ONCE per
2
+ // page. A document the server rendered for a principal carries it in the page boot (a deferred
3
+ // classic script); every other document — a static export, an anonymous page, an app with no sync
4
+ // node — gets it from the runtime chunk the island loads itself. Never both, never twice.
5
+
6
+ import { deferredScriptsPending, peekPageRealtime } from './page-store';
7
+
8
+ export interface AwaitPageRuntimeOptions {
9
+ /**
10
+ * The path every page boot is served under (`/_x/page-boot/`). A `<script>` whose `src` starts
11
+ * with it is the boot this document carries, and the runtime is coming with it.
12
+ */
13
+ readonly boot: string;
14
+ /** Imports the runtime chunk — only on a page whose boot is absent, or never ran. */
15
+ readonly load: () => Promise<unknown>;
16
+ }
17
+
18
+ type Listenable = {
19
+ addEventListener(type: string, listener: () => void, options?: { once?: boolean }): void;
20
+ };
21
+
22
+ const installed = (): boolean => {
23
+ const page = peekPageRealtime();
24
+ return page?.store !== undefined && page.services !== undefined;
25
+ };
26
+
27
+ /**
28
+ * Resolves once the page runtime is installed. An island can be imported while the parser still
29
+ * waits on the deferred boot (the idle callback fires first), so a boot that may still run is
30
+ * waited for — its `load`, its `error`, or DOMContentLoaded, which fires after every deferred
31
+ * script — and a page it never installed the runtime on loads the chunk. Never on a FUTURE event
32
+ * once the deferred scripts are done: a boot that failed before the island asked fires none again.
33
+ */
34
+ export async function awaitPageRuntime(options: AwaitPageRuntimeOptions): Promise<void> {
35
+ if (installed()) return;
36
+ const boot = bootElement(options.boot);
37
+ if (boot !== undefined) await firstOf(boot);
38
+ if (!installed()) await options.load();
39
+ }
40
+
41
+ /** The page boot's `<script>` while it may still run; `undefined` once it cannot, or with none. */
42
+ function bootElement(prefix: string): Listenable | undefined {
43
+ const doc: unknown = Reflect.get(globalThis, 'document');
44
+ if (typeof doc !== 'object' || doc === null) return undefined;
45
+ // Every deferred script has run: a boot that installed nothing failed, and is not coming.
46
+ if (!deferredScriptsPending()) return undefined;
47
+ const query: unknown = Reflect.get(doc, 'querySelector');
48
+ // A partial document (a component test's stand-in) has no query surface: no tag, no boot.
49
+ if (typeof query !== 'function') return undefined;
50
+ const found: unknown = Reflect.apply(query, doc, [`script[src^="${prefix}"]`]);
51
+ return isListenable(found) ? found : undefined;
52
+ }
53
+
54
+ function isListenable(value: unknown): value is Listenable {
55
+ return (
56
+ typeof value === 'object' &&
57
+ value !== null &&
58
+ typeof Reflect.get(value, 'addEventListener') === 'function'
59
+ );
60
+ }
61
+
62
+ /** The boot ran, failed, or every deferred script is done — whichever comes first. */
63
+ function firstOf(boot: Listenable): Promise<void> {
64
+ return new Promise<void>((resolve) => {
65
+ const done = (): void => resolve();
66
+ boot.addEventListener('load', done, { once: true });
67
+ boot.addEventListener('error', done, { once: true });
68
+ const doc: unknown = Reflect.get(globalThis, 'document');
69
+ if (isListenable(doc)) doc.addEventListener('DOMContentLoaded', done, { once: true });
70
+ if (typeof addEventListener === 'function') addEventListener('load', done, { once: true });
71
+ });
72
+ }
@@ -0,0 +1,32 @@
1
+ // The page runtime: the ONE record store, the socket stack, the query client and core's transport
2
+ // behind every hook, installed once per page onto the page object (`page-store.ts`). Never in an
3
+ // island: the page boot carries it where the server renders one, and the CLI builds it as the
4
+ // runtime chunk an island loads where it does not (`packages/cli/src/island-realtime.ts`).
5
+
6
+ import { clientTransport, onRescope, pageClient } from '@ultimat3/core/page';
7
+ import { queryClientMethodFor } from '@ultimat3/query/client';
8
+ import { pageSocket } from './page-socket';
9
+ import { type InstalledPage, pageRealtime } from './page-store';
10
+ import { RecordStore } from './record-store';
11
+
12
+ /**
13
+ * Get-or-install, idempotent: the boot and the runtime chunk may both run on one page (a boot that
14
+ * arrived after its island gave up on it), and whichever runs second adopts what the first built.
15
+ * Installs the store as core's `RecordSink`, so every HTTP answer's records land in it.
16
+ */
17
+ export function installPageRuntime(): InstalledPage {
18
+ const page = pageRealtime();
19
+ const store = page.store;
20
+ if (store !== undefined && page.services !== undefined) return page as InstalledPage;
21
+ const created = store ?? new RecordStore();
22
+ page.store = created;
23
+ page.services = {
24
+ socket: pageSocket,
25
+ read: (name) => queryClientMethodFor(name, { baseUrl: '' }),
26
+ send: clientTransport,
27
+ };
28
+ pageClient().store = created;
29
+ // A new principal sees nothing of the previous one: every record goes, in memory, at once.
30
+ onRescope(() => created.clear());
31
+ return page as InstalledPage;
32
+ }
@@ -1,6 +1,6 @@
1
- // The page's ONE socket: built on the first live or channel hook, by whichever island bundle asks
2
- // first, and shared by every other one through the page state. A form-only island never imports
3
- // this module, so it ships none of the connection lifecycle.
1
+ // The page's ONE socket: built on the first live or channel hook, and shared by every island
2
+ // through the page state. Part of the page runtime (`page-runtime.ts`), so no island bundle carries
3
+ // the connection lifecycle: a hook reaches it as `services.socket`, off the page object.
4
4
 
5
5
  import { onRescope, pageClient } from '@ultimat3/core/page';
6
6
  import { queryClientMethodFor } from '@ultimat3/query/client';
@@ -8,7 +8,7 @@ import { LiveClient } from './client';
8
8
  import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
9
9
  import { peekOutbox } from './outbox-slot';
10
10
  import { SyncUnconfiguredError } from './page-errors';
11
- import { pageRealtime } from './page-store';
11
+ import { installedPage } from './page-store';
12
12
  import {
13
13
  openHost,
14
14
  type RehostingHost,
@@ -20,7 +20,7 @@ import { pageSyncTarget, syncWorkerFromMeta } from './sync-meta';
20
20
 
21
21
  /** Get-or-create, and connect on creation. `hook` names the caller in the refusal. */
22
22
  export function pageSocket(hook: string): LiveClient {
23
- const page = pageRealtime();
23
+ const page = installedPage(hook);
24
24
  // Its one writer is below, so the stored value is always this class — from SOME bundle's copy,
25
25
  // which is why it is read structurally and never checked with `instanceof`.
26
26
  if (page.socket !== undefined) return page.socket as LiveClient;
@@ -110,17 +110,8 @@ export function resetPageSocket(
110
110
  hosts = options.openHost ?? openHost;
111
111
  }
112
112
 
113
- /**
114
- * Whether this page holds a socket — `false` on a server render and before any live hook ran.
115
- * The guard a component with a static fallback asks (an offline banner, an update prompt).
116
- */
117
- export function hasPageSocket(): boolean {
118
- const host = globalThis as { [key: symbol]: unknown };
119
- const page = host[Symbol.for('ultimate.realtime')];
120
- return (
121
- typeof page === 'object' && page !== null && (page as { socket?: unknown }).socket !== undefined
122
- );
123
- }
113
+ /** Where `hasPageSocket` lives, re-exported for the callers that reach it from here. */
114
+ export { hasPageSocket } from './page-store';
124
115
 
125
116
  function hostFor(scope: string | null, buildId: string): SocketHost {
126
117
  const workerUrl =
package/src/page-store.ts CHANGED
@@ -1,9 +1,14 @@
1
- // The page's realtime state: ONE record store and ONE sync target per tab, whichever island bundle
2
- // asks first. On `globalThis` under a `Symbol.for` key, because every island is its own bundle and
3
- // a module-scope singleton here is one store PER ISLAND — the bug this file exists to end.
1
+ // The page's realtime state as every island READS it: one object per tab on `globalThis` under a
2
+ // `Symbol.for` key, because every island is its own bundle and a module-scope singleton here is
3
+ // one store PER ISLAND. Thin by design — the store, the socket and the transport behind it are the
4
+ // page runtime's (`page-runtime.ts`), shipped once per page and never inside an island (#505).
4
5
 
5
- import { CLIENT_SCOPE_META, onRescope, pageClient } from '@ultimat3/core/page';
6
- import { RecordStore } from './record-store';
6
+ import type { clientTransport } from '@ultimat3/core/page';
7
+ import { CLIENT_SCOPE_META } from '@ultimat3/core/page';
8
+ import type { queryClientMethodFor } from '@ultimat3/query/client';
9
+ import type { LiveClient } from './client';
10
+ import { RealtimeUninstalledError } from './page-errors';
11
+ import type { RecordStore } from './record-store';
7
12
 
8
13
  /** Where the page's one socket dials. Resolved on the server and handed to the island bootstrap. */
9
14
  export interface SyncTarget {
@@ -15,7 +20,12 @@ export interface SyncTarget {
15
20
 
16
21
  /** Page-wide and shared by every bundle. Structural on purpose: no `instanceof` across copies. */
17
22
  export interface PageRealtime {
18
- readonly store: RecordStore;
23
+ /**
24
+ * The page's ONE record store — `undefined` until the page runtime is installed (the page boot,
25
+ * or the runtime chunk an island loads where no boot is rendered). A hook reads it through
26
+ * `installedPage`, which refuses by code rather than answering a store nobody shares.
27
+ */
28
+ store: RecordStore | undefined;
19
29
  sync: SyncTarget | undefined;
20
30
  /** The socket client, once a live hook opened it. Typed by `page-socket.ts`, its one writer. */
21
31
  socket: unknown;
@@ -27,8 +37,30 @@ export interface PageRealtime {
27
37
  * the first frame can race it. Resolved at once on a page that carries no boot script.
28
38
  */
29
39
  readonly booted: Promise<void>;
40
+ /** What a hook calls instead of bundling it — installed with the store, by the page runtime. */
41
+ services: PageServices | undefined;
42
+ }
43
+
44
+ /**
45
+ * The runtime's half of every hook: the work whose code would otherwise ship in each island. An
46
+ * island calls these off the page object, so the socket stack, the query client and core's
47
+ * transport are in the page runtime once, however many islands the page renders.
48
+ */
49
+ export interface PageServices {
50
+ /** The page's one socket, opened on the first ask. `hook` names the caller in a refusal. */
51
+ socket(hook: string): LiveClient;
52
+ /** A non-live query read over HTTP — `@ultimat3/query`'s client, through core's transport. */
53
+ read(name: string): ReturnType<typeof queryClientMethodFor>;
54
+ /** Core's one transport, for a write's POST. */
55
+ send: typeof clientTransport;
30
56
  }
31
57
 
58
+ /** The page once its runtime is installed: what every hook works against. */
59
+ export type InstalledPage = PageRealtime & {
60
+ readonly store: RecordStore;
61
+ readonly services: PageServices;
62
+ };
63
+
32
64
  /** Every optimistic write on the page, counted per mutator name, and who renders the counts. */
33
65
  export interface PageWrites {
34
66
  readonly pending: Map<string, number>;
@@ -86,9 +118,32 @@ export function bootedPromise(): Promise<void> {
86
118
  return waiting;
87
119
  }
88
120
 
121
+ /**
122
+ * Whether a deferred `<script>` of this document may still run. Not past `loading`, yes; past
123
+ * `complete`, no. In between (`interactive`) the deferred scripts run, then DOMContentLoaded fires —
124
+ * and `readyState` stays `interactive` until every image has loaded, so the navigation timing's
125
+ * `domContentLoadedEventStart` is what says they have all run (a boot that 404'd among them). A
126
+ * document with no such reading is assumed still running them: a caller then waits on DOMContentLoaded.
127
+ */
128
+ export function deferredScriptsPending(): boolean {
129
+ if (typeof document === 'undefined') return false;
130
+ const state: unknown = document.readyState;
131
+ if (state === 'complete') return false;
132
+ if (state !== 'interactive') return true;
133
+ const timing: unknown =
134
+ typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function'
135
+ ? undefined
136
+ : performance.getEntriesByType('navigation')[0];
137
+ const started: unknown =
138
+ typeof timing === 'object' && timing !== null
139
+ ? Reflect.get(timing, 'domContentLoadedEventStart')
140
+ : undefined;
141
+ return !(typeof started === 'number' && started > 0);
142
+ }
143
+
89
144
  function bootComing(): boolean {
90
145
  if (typeof document === 'undefined' || typeof addEventListener !== 'function') return false;
91
- if (document.readyState === 'complete') return false;
146
+ if (!deferredScriptsPending()) return false;
92
147
  // A partial `document` (a component test's stand-in) has no query surface: no tag, no boot.
93
148
  if (typeof document.querySelector !== 'function') return false;
94
149
  return document.querySelector(`meta[name="${CLIENT_SCOPE_META}"]`) !== null;
@@ -96,25 +151,25 @@ function bootComing(): boolean {
96
151
 
97
152
  type Host = { [KEY]?: PageRealtime };
98
153
 
99
- /** Get-or-create. Installs the store as core's `RecordSink`, so HTTP answers adopt into it. */
154
+ /**
155
+ * Get-or-create the page object — never its runtime: an island that asks before the runtime is
156
+ * installed (`installRealtime` seating a sync target) gets the object the runtime then fills.
157
+ */
100
158
  export function pageRealtime(): PageRealtime {
101
159
  const host = globalThis as Host;
102
160
  const existing = host[KEY];
103
161
  if (existing !== undefined) return existing;
104
- const store = new RecordStore();
105
162
  const created: PageRealtime = {
106
- store,
163
+ store: undefined,
107
164
  sync: undefined,
108
165
  socket: undefined,
109
166
  writes: { pending: new Map(), failed: 0, listeners: new Set() },
110
167
  get booted(): Promise<void> {
111
168
  return bootedPromise();
112
169
  },
170
+ services: undefined,
113
171
  };
114
172
  Object.defineProperty(host, KEY, { value: created, configurable: true });
115
- pageClient().store = store;
116
- // A new principal sees nothing of the previous one: every record goes, in memory, at once.
117
- onRescope(() => store.clear());
118
173
  return created;
119
174
  }
120
175
 
@@ -123,6 +178,18 @@ export function peekPageRealtime(): PageRealtime | undefined {
123
178
  return (globalThis as Host)[KEY];
124
179
  }
125
180
 
181
+ /**
182
+ * The page with its runtime installed, or `X_REALTIME_UNINSTALLED` naming `hook`. An island `x
183
+ * build` wrapped never sees the refusal — its bootstrap awaits the runtime before any hook runs.
184
+ */
185
+ export function installedPage(hook: string): InstalledPage {
186
+ const page = pageRealtime();
187
+ if (page.store === undefined || page.services === undefined) {
188
+ throw new RealtimeUninstalledError({ hook });
189
+ }
190
+ return page as InstalledPage;
191
+ }
192
+
126
193
  /**
127
194
  * Whether this page holds a socket — `false` on a server render and before any live hook ran.
128
195
  * The guard a component with a static fallback asks (an offline banner, an update prompt). Here,
@@ -132,7 +199,7 @@ export function hasPageSocket(): boolean {
132
199
  return peekPageRealtime()?.socket !== undefined;
133
200
  }
134
201
 
135
- /** The page's record store. */
136
- export function pageStore(): RecordStore {
137
- return pageRealtime().store;
202
+ /** The page's record store, from its installed runtime. */
203
+ export function pageStore(hook = 'pageStore'): RecordStore {
204
+ return installedPage(hook).store;
138
205
  }