@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.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  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 +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -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 +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -1,241 +0,0 @@
1
- // Tier 3: the durable local store a `mutator`'s `local(tx, input)` half writes to.
2
- //
3
- // `local` must be replayable — no I/O, no Date.now(), no Math.random() — because rebase replays it.
4
- // That is why every write goes through a journal keyed by the mutation's idempotency key: rollback
5
- // is "undo this key's journal in reverse", not "re-fetch and hope".
6
- //
7
- // A table owns MEMBERSHIP (which ids it holds); the shared `IdentityMap` owns the VALUES, so an
8
- // optimistic write and the live query rendering that row are one row, not two copies.
9
-
10
- import { NotImplementedError } from './errors';
11
- import { IdentityMap } from './identity-map';
12
- import type { Row } from './json';
13
-
14
- export interface LocalTable<R extends Row = Row> {
15
- get(id: string): R | undefined;
16
- all(): readonly R[];
17
- insert(row: R): void;
18
- upsert(row: R): void;
19
- /** `patch` returns changed fields only, mirroring the canonical mutator example. */
20
- update(id: string, patch: (row: R) => Partial<R>): void;
21
- delete(id: string): void;
22
- }
23
-
24
- export type TableMap = Record<string, Row>;
25
-
26
- /**
27
- * The transaction handle passed to `local`. In a generated app the table map comes from the app's
28
- * entities, so `tx.posts` is a real property with a real row type — never an index signature.
29
- */
30
- export type LocalTx<T extends TableMap = TableMap> = { readonly [K in keyof T]: LocalTable<T[K]> };
31
-
32
- interface JournalEntry {
33
- readonly table: string;
34
- readonly id: string;
35
- /** Row state before the write; `undefined` means "did not exist" (so undo = drop membership). */
36
- readonly before: Row | undefined;
37
- }
38
-
39
- export interface LocalStore<T extends TableMap = TableMap> {
40
- /**
41
- * The one map every row value lives in, shared with the live-query windows on the same client.
42
- * A `LiveClient` reads it off the store rather than building its own — two identity maps in one
43
- * client is the bug an identity map exists to prevent, one level up.
44
- */
45
- readonly identity: IdentityMap;
46
- readonly tx: LocalTx<T>;
47
- table(name: string): LocalTable;
48
- /** Runs `fn` while journalling every write under `key`, so it can be rolled back verbatim. */
49
- apply(key: string, fn: (tx: LocalTx<T>) => void): void;
50
- /** Undo one key's writes, newest first. Used by rebase before reapplying pending mutations. */
51
- rollback(key: string): void;
52
- /** Server confirmed: drop the journal. After this the write is no longer optimistic. */
53
- commit(key: string): void;
54
- pendingKeys(): readonly string[];
55
- snapshot(name: string): readonly Row[];
56
- reset(tables: Readonly<Record<string, readonly Row[]>>): void;
57
- }
58
-
59
- /**
60
- * The reference implementation. Every rule in the tier-3 contract (journalling, ordered undo, key
61
- * scoping) is implemented here; OPFS SQLite swaps the storage, not the semantics.
62
- */
63
- export class MemoryLocalStore<T extends TableMap = TableMap> implements LocalStore<T> {
64
- /** Membership and nothing else: `#members.get('posts')` is which ids this table holds. */
65
- readonly #members = new Map<string, Set<string>>();
66
- readonly #journals = new Map<string, JournalEntry[]>();
67
- #recordingKey: string | null = null;
68
-
69
- readonly identity: IdentityMap;
70
- readonly tx: LocalTx<T>;
71
-
72
- constructor(tables: Readonly<Record<string, readonly Row[]>> = {}, identity = new IdentityMap()) {
73
- this.identity = identity;
74
- this.reset(tables);
75
- const handler: ProxyHandler<Record<string, LocalTable>> = {
76
- // Symbols (`Symbol.iterator`, `then`) must not resolve to a table, or awaiting a tx would
77
- // silently create one.
78
- get: (_target, property) => (typeof property === 'symbol' ? undefined : this.table(property)),
79
- };
80
- this.tx = new Proxy({} as Record<string, LocalTable>, handler) as LocalTx<T>;
81
- }
82
-
83
- table(name: string): LocalTable {
84
- const ids = this.#ids(name);
85
- const read = (id: string): Row | undefined =>
86
- ids.has(id) ? this.identity.peek(name, id) : undefined;
87
- return {
88
- get: read,
89
- all: () => {
90
- const rows: Row[] = [];
91
- for (const id of ids) {
92
- const row = this.identity.peek(name, id);
93
- if (row !== undefined) rows.push(row);
94
- }
95
- return rows;
96
- },
97
- insert: (row) => {
98
- this.#journal(name, row.id, read(row.id));
99
- this.#join(name, row.id, ids);
100
- this.identity.set(name, row);
101
- },
102
- upsert: (row) => {
103
- this.#journal(name, row.id, read(row.id));
104
- this.#join(name, row.id, ids);
105
- this.identity.merge(name, row.id, row);
106
- },
107
- update: (id, patch) => {
108
- const current = read(id);
109
- if (!current) return;
110
- this.#journal(name, id, current);
111
- this.identity.merge(name, id, patch(current));
112
- },
113
- delete: (id) => {
114
- const current = read(id);
115
- if (!current) return;
116
- this.#journal(name, id, current);
117
- this.#leave(name, id, ids);
118
- },
119
- };
120
- }
121
-
122
- apply(key: string, fn: (tx: LocalTx<T>) => void): void {
123
- const previous = this.#recordingKey;
124
- this.#recordingKey = key;
125
- if (!this.#journals.has(key)) this.#journals.set(key, []);
126
- try {
127
- // One notification for the whole twin: a mutator touching twenty rows is one render.
128
- this.identity.batch(() => fn(this.tx));
129
- } finally {
130
- this.#recordingKey = previous;
131
- }
132
- }
133
-
134
- rollback(key: string): void {
135
- const journal = this.#journals.get(key);
136
- if (!journal) return;
137
- this.identity.batch(() => {
138
- for (let i = journal.length - 1; i >= 0; i -= 1) {
139
- const entry = journal[i];
140
- if (!entry) continue;
141
- const ids = this.#ids(entry.table);
142
- if (entry.before === undefined) {
143
- this.#leave(entry.table, entry.id, ids);
144
- continue;
145
- }
146
- this.#join(entry.table, entry.id, ids);
147
- this.identity.set(entry.table, entry.before);
148
- }
149
- });
150
- this.#journals.delete(key);
151
- }
152
-
153
- commit(key: string): void {
154
- this.#journals.delete(key);
155
- }
156
-
157
- pendingKeys(): readonly string[] {
158
- return [...this.#journals.keys()];
159
- }
160
-
161
- snapshot(name: string): readonly Row[] {
162
- return this.table(name).all();
163
- }
164
-
165
- reset(tables: Readonly<Record<string, readonly Row[]>>): void {
166
- this.identity.batch(() => {
167
- for (const [name, ids] of this.#members) {
168
- for (const id of ids) this.identity.release(name, id);
169
- }
170
- this.#members.clear();
171
- this.#journals.clear();
172
- for (const [name, rows] of Object.entries(tables)) {
173
- const ids = this.#ids(name);
174
- for (const row of rows) {
175
- this.#join(name, row.id, ids);
176
- this.identity.set(name, row);
177
- }
178
- }
179
- });
180
- }
181
-
182
- #ids(name: string): Set<string> {
183
- const existing = this.#members.get(name);
184
- if (existing) return existing;
185
- const created = new Set<string>();
186
- this.#members.set(name, created);
187
- return created;
188
- }
189
-
190
- /** Membership is what holds a value in the map, so joining and retaining are one step. */
191
- #join(name: string, id: string, ids: Set<string>): void {
192
- if (ids.has(id)) return;
193
- ids.add(id);
194
- this.identity.retain(name, id);
195
- }
196
-
197
- /** Leaving releases: the value survives only while a live window still holds the same row. */
198
- #leave(name: string, id: string, ids: Set<string>): void {
199
- if (!ids.delete(id)) return;
200
- this.identity.release(name, id);
201
- }
202
-
203
- /** Only the *first* write to a row within one key is journalled — undo must reach the base state. */
204
- #journal(table: string, id: string, before: Row | undefined): void {
205
- const key = this.#recordingKey;
206
- if (key === null) return;
207
- const journal = this.#journals.get(key);
208
- if (!journal) return;
209
- if (journal.some((entry) => entry.table === table && entry.id === id)) return;
210
- journal.push(before === undefined ? { table, id, before: undefined } : { table, id, before });
211
- }
212
- }
213
-
214
- export interface OpfsLocalStoreOptions {
215
- /** OPFS file name, versioned so a client-side migration can run before first read. */
216
- readonly file: string;
217
- readonly schemaVersion: number;
218
- }
219
-
220
- /**
221
- * The production tier-3 store: SQLite over the Origin Private File System, opened in a worker so a
222
- * long write never blocks the main thread. Browser-only, so it must not be reachable from a server
223
- * bundle — which is why it is a factory that throws rather than a class you can accidentally new
224
- * on the server.
225
- *
226
- * The refusal below is the whole of what tier 3's durable half ships today, so its two lines have
227
- * to be true of THIS build. Both were false until 2026-08-23: the fix told the caller to import
228
- * this factory from a `/browser` subpath, which `package.json`'s `exports` has never declared —
229
- * two entries ship, `.` and `./server` — so pasting it ended in a module-resolution failure. Its
230
- * alternative was `persist: false` on the query, which `query()` has never accepted either (a
231
- * `TS2353` excess property). An instruction that cannot run is axiom 4 failing in the package that
232
- * documents it, so the fix now names an export that exists on an entry that exists:
233
- * `MemoryLocalStore`, on `.`, declared beside this factory. `fix-specifier.test.ts` is the
234
- * mechanical half.
235
- */
236
- export function createOpfsLocalStore(options: OpfsLocalStoreOptions): LocalStore {
237
- throw new NotImplementedError({
238
- what: `the OPFS SQLite local store for ${options.file} (schema v${options.schemaVersion}), which is realtime tier 3's durable half,`,
239
- fix: "replace createOpfsLocalStore(...) with new MemoryLocalStore(), imported from '@ultimat3/realtime' beside it: the same LocalStore contract — journalled writes, ordered rollback, one row value per (entity, id) — held for the tab's lifetime rather than across a reload",
240
- });
241
- }
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
- }