@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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
package/src/query-hook.ts DELETED
@@ -1,56 +0,0 @@
1
- // The typed projection of a query into the hook a component calls: `liveHookFor(liveFeed)` is
2
- // `useLiveFeed`, and `useLiveFeed({ orgId })` carries that query's own input and row types. It
3
- // binds `useLive` rather than re-implementing it — one subscribe path, given the query's name.
4
-
5
- import { QueryNotSubscribableError } from './errors';
6
- import { type LiveInput, type LiveRows, useLive } from './hooks';
7
-
8
- /**
9
- * What the hook needs from a `@ultimat3/query` `Query`: the name it subscribes under, the declared
10
- * `live:` flag, and the call signature both types are read off. Named structurally rather than
11
- * imported, the way `hooks.ts` names a mutator — a hook is browser code, and a value import of
12
- * `@ultimat3/query` would carry the server's read path into the bundle.
13
- *
14
- * `options` is `never` because this side never passes one; a `Query`, whose second parameter is
15
- * optional and wider, still assigns.
16
- */
17
- export interface LiveQuerySource<TInput, TRow extends object> {
18
- (input: TInput, options?: never): Promise<readonly TRow[]>;
19
- readonly name: string;
20
- readonly isLive: boolean;
21
- }
22
-
23
- /**
24
- * The bound hook. Input is the query's own — a wrong key is a compile error in the component, not
25
- * a subscription that returns nothing. A thunk is read **once**, at subscribe time, exactly as
26
- * `useLive`'s is: there is no reactive runtime here to re-run it, so new input means a new
27
- * subscription.
28
- */
29
- export type LiveQueryHook<TInput, TRow extends object> = (
30
- input: TInput | (() => TInput),
31
- ) => LiveRows<TRow>;
32
-
33
- /**
34
- * Bind one live query to one hook: `export const useLiveFeed = liveHookFor(liveFeed)`, then
35
- * `useLiveFeed({ orgId })` in a component. Nothing is generated and nothing is fetched by hand —
36
- * the types come off the query declaration and the rows off its subscription.
37
- *
38
- * Binding a query that is not `live: true` throws here, at module load, rather than handing back a
39
- * hook that could only ever return an empty set.
40
- */
41
- export function liveHookFor<TInput, TRow extends object>(
42
- query: LiveQuerySource<TInput, TRow>,
43
- ): LiveQueryHook<TInput, TRow> {
44
- if (!query.isLive) throw new QueryNotSubscribableError({ name: query.name });
45
- // The query object is handed through, never its `name` read now: `registerQueries()` stamps that
46
- // at boot, and this binding runs at import — earlier. `useLive` reads it per subscription.
47
- return (input) => {
48
- // Both assertions are the one wire seam: the input is about to be serialised into a subscribe
49
- // frame, and the rows come back off that subscription. `unknown` in between rather than a
50
- // direct cast, exactly as `query.client()` hops through it at the same seam.
51
- const rows: unknown = useLive(query, input as LiveInput);
52
- // Erased at the wire seam, the way `query.client()` erases its own: the rows arriving on this
53
- // subscription are this query's by construction, because the server built them from its `sql`.
54
- return rows as LiveRows<TRow>;
55
- };
56
- }
package/src/rebase.ts DELETED
@@ -1,263 +0,0 @@
1
- // Tier 3: the reconcile loop. The server rebases; the client rolls back and reapplies.
2
- //
3
- // Truth is always the server — a client is never the merge authority. So reconciliation is exactly:
4
- // undo the optimistic writes from the acknowledged mutation onward (newest first), land server
5
- // truth, then replay the still-pending mutators in sequence order. Because `local` is a pure
6
- // function of `(tx, input)`, that replay is deterministic; that purity rule is the whole reason
7
- // tier 3 is affordable.
8
-
9
- import { RebaseConflictError } from './errors';
10
- import type { Row } from './json';
11
- import type { LocalStore, LocalTx, TableMap } from './local-store';
12
- import { type ConflictStrategyName, PROTOCOL_VERSION, type RebaseFrame } from './sync-protocol';
13
-
14
- export interface MergeArgs {
15
- /** Local row as the user last saw it, before any rollback. */
16
- readonly local: Row | undefined;
17
- /** Local row after the optimistic writes were undone — the shared base. */
18
- readonly base: Row | undefined;
19
- /** Server truth. `null` means the server deleted the row. */
20
- readonly server: Row | null;
21
- }
22
-
23
- export interface CustomMerge {
24
- readonly kind: 'custom';
25
- merge(args: MergeArgs): Row | null | undefined;
26
- }
27
-
28
- /** `conflict: custom(merge)` — exactly the name the mutator contract uses. */
29
- export function custom(merge: (args: MergeArgs) => Row | null | undefined): CustomMerge {
30
- return { kind: 'custom', merge };
31
- }
32
-
33
- export type ConflictStrategy = 'server-wins' | 'last-write-wins' | CustomMerge;
34
-
35
- export function strategyName(strategy: ConflictStrategy): ConflictStrategyName {
36
- return typeof strategy === 'string' ? strategy : 'custom';
37
- }
38
-
39
- export interface RebaseEntry<T extends TableMap = TableMap> {
40
- /** Idempotency key — the same key the offline queue uses. */
41
- readonly key: string;
42
- readonly seq: number;
43
- readonly entity: string;
44
- readonly strategy: ConflictStrategy;
45
- /** The mutator's `local` half, curried with its input. Pure, therefore replayable. */
46
- apply(tx: LocalTx<T>): void;
47
- }
48
-
49
- /** Optimistic writes awaiting server truth, ordered by client sequence. */
50
- export class RebaseLog<T extends TableMap = TableMap> {
51
- readonly #entries = new Map<string, RebaseEntry<T>>();
52
-
53
- record(entry: RebaseEntry<T>): void {
54
- this.#entries.set(entry.key, entry);
55
- }
56
-
57
- get(key: string): RebaseEntry<T> | undefined {
58
- return this.#entries.get(key);
59
- }
60
-
61
- drop(key: string): void {
62
- this.#entries.delete(key);
63
- }
64
-
65
- pending(): readonly RebaseEntry<T>[] {
66
- return [...this.#entries.values()].sort((a, b) => a.seq - b.seq);
67
- }
68
-
69
- get size(): number {
70
- return this.#entries.size;
71
- }
72
- }
73
-
74
- export interface ServerAck {
75
- readonly key: string;
76
- readonly entity: string;
77
- readonly id: string;
78
- readonly row: Row | null;
79
- }
80
-
81
- export interface ReconcileOptions {
82
- /** Field compared by `last-write-wins`. Must be a number (epoch ms) written by the server. */
83
- readonly clockField?: string;
84
- }
85
-
86
- export interface ReconcileResult {
87
- readonly strategy: ConflictStrategyName;
88
- readonly rolledBack: readonly string[];
89
- readonly reapplied: readonly string[];
90
- readonly winner: 'server' | 'local' | 'merge';
91
- }
92
-
93
- /**
94
- * One acknowledgement, one rebase. Rolls back the acked mutation and every later optimistic write,
95
- * lands server truth under the mutator's conflict strategy, then replays the rest in sequence order.
96
- */
97
- export function reconcile<T extends TableMap = TableMap>(
98
- args: {
99
- store: LocalStore<T>;
100
- log: RebaseLog<T>;
101
- ack: ServerAck;
102
- },
103
- options: ReconcileOptions = {},
104
- ): ReconcileResult {
105
- const { store, log, ack } = args;
106
- const entry = log.get(ack.key);
107
- const strategy = entry?.strategy ?? 'server-wins';
108
- const table = store.table(ack.entity);
109
- const local = table.get(ack.id);
110
-
111
- const { affected, rolledBack } = undoFrom(store, log, entry?.seq ?? 0);
112
-
113
- const base = store.table(ack.entity).get(ack.id);
114
- const winner = land(store, ack, strategy, { local, base }, options);
115
- log.drop(ack.key);
116
-
117
- return {
118
- strategy: strategyName(strategy),
119
- rolledBack,
120
- reapplied: replayExcept(store, affected, ack.key),
121
- winner,
122
- };
123
- }
124
-
125
- /**
126
- * Everything at or after `from` is optimistic, so it is undone newest-first — and `reconcile` and
127
- * `rollbackMutation` are one rule with different middles, not two. Spelled twice, the next change
128
- * to the replay order has to be made twice, and the half that is missed diverges silently.
129
- */
130
- function undoFrom<T extends TableMap>(
131
- store: LocalStore<T>,
132
- log: RebaseLog<T>,
133
- from: number,
134
- ): { affected: readonly RebaseEntry<T>[]; rolledBack: string[] } {
135
- const affected = log.pending().filter((candidate) => candidate.seq >= from);
136
- const rolledBack: string[] = [];
137
- for (const candidate of [...affected].reverse()) {
138
- store.rollback(candidate.key);
139
- rolledBack.push(candidate.key);
140
- }
141
- return { affected, rolledBack };
142
- }
143
-
144
- /**
145
- * The other half: replay in sequence order, skipping the one the server has now settled. `local` is
146
- * pure, which is what makes replaying it deterministic and therefore safe to do at all.
147
- */
148
- function replayExcept<T extends TableMap>(
149
- store: LocalStore<T>,
150
- affected: readonly RebaseEntry<T>[],
151
- settled: string,
152
- ): string[] {
153
- const reapplied: string[] = [];
154
- for (const candidate of affected) {
155
- if (candidate.key === settled) continue;
156
- store.apply(candidate.key, (tx) => candidate.apply(tx));
157
- reapplied.push(candidate.key);
158
- }
159
- return reapplied;
160
- }
161
-
162
- export interface RollbackResult {
163
- readonly rolledBack: readonly string[];
164
- readonly reapplied: readonly string[];
165
- }
166
-
167
- /**
168
- * The other half of `reconcile`: the server **refused** a mutation, so there is no server truth to
169
- * land — only an optimistic write to take back. Same shape as a reconcile, and for the same reason:
170
- * the writes made after it may depend on it, so everything from its sequence onward is undone
171
- * newest-first and then replayed without it. Replay is deterministic because `local` is pure.
172
- *
173
- * Idempotent for a key the log does not hold: a denial can arrive twice, and tier 2 records nothing
174
- * to undo in the first place.
175
- */
176
- export function rollbackMutation<T extends TableMap = TableMap>(args: {
177
- store: LocalStore<T>;
178
- log: RebaseLog<T>;
179
- key: string;
180
- }): RollbackResult {
181
- const { store, log, key } = args;
182
- const entry = log.get(key);
183
- if (!entry) return { rolledBack: [], reapplied: [] };
184
-
185
- const { affected, rolledBack } = undoFrom(store, log, entry.seq);
186
- // The one thing that differs from `reconcile`'s middle: there is no server truth to land. Dropped
187
- // and never retried — a denial is a decision about this intent, so replaying it on the next
188
- // reconcile would put the write the server refused back on the screen.
189
- log.drop(key);
190
- return { rolledBack, reapplied: replayExcept(store, affected, key) };
191
- }
192
-
193
- function land<T extends TableMap>(
194
- store: LocalStore<T>,
195
- ack: ServerAck,
196
- strategy: ConflictStrategy,
197
- rows: { local: Row | undefined; base: Row | undefined },
198
- options: ReconcileOptions,
199
- ): 'server' | 'local' | 'merge' {
200
- const table = store.table(ack.entity);
201
-
202
- if (strategy === 'server-wins') {
203
- write(store, ack.entity, ack.id, ack.row);
204
- return 'server';
205
- }
206
-
207
- if (strategy === 'last-write-wins') {
208
- const field = options.clockField ?? 'updatedAt';
209
- const localAt = numberAt(rows.local, field);
210
- const serverAt = numberAt(ack.row, field);
211
- if (localAt !== null && serverAt !== null && localAt > serverAt) {
212
- // The local write is newer by the server's own clock field: keep it, do not clobber.
213
- if (rows.local) table.upsert(rows.local);
214
- return 'local';
215
- }
216
- write(store, ack.entity, ack.id, ack.row);
217
- return 'server';
218
- }
219
-
220
- const merged = strategy.merge({ local: rows.local, base: rows.base, server: ack.row });
221
- if (merged === undefined) {
222
- throw new RebaseConflictError({
223
- key: ack.key,
224
- entity: ack.entity,
225
- reason: 'custom(merge) returned undefined; return a row, or null to accept the delete',
226
- });
227
- }
228
- write(store, ack.entity, ack.id, merged);
229
- return 'merge';
230
- }
231
-
232
- function write<T extends TableMap>(
233
- store: LocalStore<T>,
234
- entity: string,
235
- id: string,
236
- row: Row | null,
237
- ): void {
238
- const table = store.table(entity);
239
- if (row === null) table.delete(id);
240
- else table.upsert(row);
241
- }
242
-
243
- function numberAt(row: Row | null | undefined, field: string): number | null {
244
- if (!row) return null;
245
- const value = row[field];
246
- return typeof value === 'number' ? value : null;
247
- }
248
-
249
- /**
250
- * `RebaseFrame`, not `Frame`: this builds exactly one member of the union and declaring the whole
251
- * union threw that away, so every caller had to re-narrow a frame it had just constructed before it
252
- * could read `strategy` or `row` back off it.
253
- */
254
- export function rebaseFrame(ack: ServerAck, strategy: ConflictStrategy): RebaseFrame {
255
- return {
256
- type: 'rebase',
257
- v: PROTOCOL_VERSION,
258
- key: ack.key,
259
- entity: ack.entity,
260
- strategy: strategyName(strategy),
261
- row: ack.row,
262
- };
263
- }
@@ -1,96 +0,0 @@
1
- // What a live client IS on the server: one that serves the first render and opens no socket.
2
- //
3
- // The rule it exists for is `@ultimat3/ui`'s, one package over — no runtime and no DOM is a SERVER
4
- // RENDER, and a server render gets an honest account of itself rather than a throw. A page whose
5
- // whole body reads a live query could not server-render at all before this: `useConnection()` threw
6
- // `X_LIVE_CLIENT_MISSING` and the route answered 500 (issue #271).
7
- //
8
- // It implements `LiveClientLike` and imports NO connection lifecycle — no `LiveClient`, no
9
- // heartbeat, no wire protocol. Measured: reaching the class from here costs every island that
10
- // calls `useLive` 18 kB it can never run.
11
-
12
- import type { LiveClientLike, LiveHandle, LiveQueryRef, SignalFactory } from './client-contract';
13
- import { ServerRenderLiveError } from './errors';
14
- import type { JsonValue, Row } from './json';
15
- import type { LiveState } from './live-rows';
16
-
17
- /**
18
- * A signal that never changes, because nothing on the server can change it: one render, one pass,
19
- * no reactive runtime. The setter is kept rather than dropped so a caller that writes through it
20
- * reads its own write back — a signal that swallowed writes would be a different lie.
21
- */
22
- const inertSignal: SignalFactory = <T>(initial: T): [() => T, (next: T) => void] => {
23
- let held = initial;
24
- return [
25
- (): T => held,
26
- (next: T): void => {
27
- held = next;
28
- },
29
- ];
30
- };
31
-
32
- /** Frozen, so a handle a page holds cannot be turned into a result set by writing to it. */
33
- const NO_ROWS: readonly Row[] = Object.freeze([]);
34
-
35
- /** Nothing was subscribed, so nothing is released — and a teardown never fails a render. */
36
- const releaseNothing = (): void => undefined;
37
-
38
- /**
39
- * The handle a server render gets for a live query: `loading`, never `offline` and never `live`.
40
- *
41
- * That is the one honest state — the rows arrive over a socket this render does not have, so the
42
- * page's own loading fallback is what the document carries until hydration replaces it. `offline`
43
- * would be read as a SETTLED answer (`state() !== 'loading'` is the gate `examples/dummy`'s feed
44
- * uses), so an empty result set would render "you have no posts" for a feed that has some.
45
- */
46
- function serverRenderHandle<R extends Row>(): LiveHandle<R> {
47
- return {
48
- rows: () => NO_ROWS as readonly R[],
49
- state: (): LiveState => 'loading',
50
- cursor: () => null,
51
- unsubscribe: releaseNothing,
52
- [Symbol.dispose]: releaseNothing,
53
- };
54
- }
55
-
56
- /**
57
- * Every member that can only mean "talk to the socket" refuses; every member a render READS
58
- * answers what a server render actually is.
59
- *
60
- * `connected: true` is not a lie about the socket — `useConnection().offline` is a banner about
61
- * THIS visitor's connectivity, and the request being served is the proof it is up. Answering
62
- * `false` would server-render "you are offline" into every document, for a reader who is not, and
63
- * then remove it on hydrate.
64
- *
65
- * It registers nothing, which is what makes ONE instance per process safe under concurrent
66
- * renders: a client that kept a registration per `useLive` would grow by one entry per request,
67
- * forever, and hold a row window with each.
68
- */
69
- function build(): LiveClientLike {
70
- return {
71
- signal: inertSignal,
72
- queue: undefined,
73
- connected: true,
74
- reconnectAt: () => null,
75
- appUpdateAvailable: () => null,
76
- useLive: <R extends Row>(_query: LiveQueryRef, _input: JsonValue): LiveHandle<R> =>
77
- serverRenderHandle<R>(),
78
- mutate: (): Promise<void> => {
79
- throw new ServerRenderLiveError({ operation: 'mutate()' });
80
- },
81
- drain: (): Promise<void> => {
82
- throw new ServerRenderLiveError({ operation: 'drain()' });
83
- },
84
- // A listener is accepted and never called: nothing on the server can change a queue that does
85
- // not exist. Refusing here would break `setLiveClient`, which registers one unconditionally.
86
- onQueueChange: () => releaseNothing,
87
- };
88
- }
89
-
90
- let held: LiveClientLike | null = null;
91
-
92
- /** ONE per process, built on first use. It holds nothing per request — see `build` above. */
93
- export function serverRenderLiveClient(): LiveClientLike {
94
- held ??= build();
95
- return held;
96
- }