@ultimat3/realtime 20.2.1 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/hooks.ts
DELETED
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
// The four calls a component makes: one live query, one connection view, one mutator call, one
|
|
2
|
-
// queue view. Nothing here is a reactive runtime — realtime never imports solid-js, so every
|
|
3
|
-
// accessor is a closure over the `SignalFactory` the registered `LiveClient` was built with, and
|
|
4
|
-
// every hook resolves that client through one ambient seam rather than a context per surface.
|
|
5
|
-
|
|
6
|
-
import type { LiveClientLike, LiveHandle, LiveQueryRef, MutatorRef } from './client';
|
|
7
|
-
import { LiveClientMissingError } from './errors';
|
|
8
|
-
import type { JsonValue, Row } from './json';
|
|
9
|
-
import type { LocalTx } from './local-store';
|
|
10
|
-
import type { ConflictStrategy } from './rebase';
|
|
11
|
-
import { serverRenderLiveClient } from './server-render-client';
|
|
12
|
-
|
|
13
|
-
/** What `setLiveClient` holds. The version signal is the queue's only reactive handle — see below. */
|
|
14
|
-
interface Registered {
|
|
15
|
-
readonly client: LiveClientLike;
|
|
16
|
-
/** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
|
|
17
|
-
readonly version: () => number;
|
|
18
|
-
readonly bump: () => void;
|
|
19
|
-
/** Drops this registration's queue listener. The client outlives the registration. */
|
|
20
|
-
readonly release: () => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
let registered: Registered | null = null;
|
|
24
|
-
|
|
25
|
-
/** Register once, in the app entry, before the first render. One app, one socket, one client. */
|
|
26
|
-
export function setLiveClient(client: LiveClientLike): void {
|
|
27
|
-
// The previous registration's listener goes with it. The client outlives `setLiveClient` — a hot
|
|
28
|
-
// reload, a test's next case, an app that re-registers after signing in — so a discarded
|
|
29
|
-
// unsubscribe is a listener nothing can reach, bumping a signal nothing renders, once per
|
|
30
|
-
// registration this process ever made.
|
|
31
|
-
registered?.release();
|
|
32
|
-
const [version, setVersion] = client.signal<number>(0);
|
|
33
|
-
const bump = (): void => {
|
|
34
|
-
setVersion(version() + 1);
|
|
35
|
-
};
|
|
36
|
-
// Closes the gap a direct call can't: a reconnect drains automatically inside `connect()`, and
|
|
37
|
-
// an ack/fail frame arrives asynchronously inside `#onFrame` — neither is awaited by any hook, so
|
|
38
|
-
// this is the only path that reaches them. The direct `bump()` calls below stay too: they fire at
|
|
39
|
-
// the earliest possible moment for the call that made them, and a redundant bump is harmless.
|
|
40
|
-
const release = client.onQueueChange(bump);
|
|
41
|
-
registered = { client, version, bump, release };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** For tests: drop the registration so cases stay independent. */
|
|
45
|
-
export function clearLiveClient(): void {
|
|
46
|
-
registered?.release();
|
|
47
|
-
registered = null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function hasLiveClient(): boolean {
|
|
51
|
-
return registered !== null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A DOM is the whole of the question — the same probe and the same rule `@ultimat3/ui`'s `solid()`
|
|
56
|
-
* follows, and deliberately the same words. With a DOM, a hook reaching for a client nobody
|
|
57
|
-
* registered is a real bug: the app entry forgot `setLiveClient`, and every live query on the page
|
|
58
|
-
* is dead. Without one there is no socket a client could have been registered FOR — that is a
|
|
59
|
-
* server render, and `serverRenderLiveClient()` is an honest account of it rather than a
|
|
60
|
-
* degradation of a working path. Never widen this to "no client, never throw": that is the silent
|
|
61
|
-
* feed-that-never-loads the split exists to prevent.
|
|
62
|
-
*/
|
|
63
|
-
function hasDom(): boolean {
|
|
64
|
-
return typeof document !== 'undefined' && typeof window !== 'undefined';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* The server render's registration, built once. Deliberately NOT written to `registered`:
|
|
69
|
-
* `hasLiveClient()` must keep answering `false` on the server, because that is the guard a
|
|
70
|
-
* component with a static fallback already uses to decide it is being server-rendered
|
|
71
|
-
* (`examples/dummy`'s `update-banner.tsx`).
|
|
72
|
-
*/
|
|
73
|
-
let serverSide: Registered | null = null;
|
|
74
|
-
|
|
75
|
-
function serverRegistration(): Registered {
|
|
76
|
-
if (serverSide !== null) return serverSide;
|
|
77
|
-
const client = serverRenderLiveClient();
|
|
78
|
-
// The version signal never moves, and nothing on the server can move it: one pass, no queue,
|
|
79
|
-
// no ack — so `bump` and `release` are the no-ops that fact makes them.
|
|
80
|
-
const [version] = client.signal<number>(0);
|
|
81
|
-
serverSide = { client, version, bump: () => undefined, release: () => undefined };
|
|
82
|
-
return serverSide;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function live(hook: string): Registered {
|
|
86
|
-
if (registered !== null) return registered;
|
|
87
|
-
if (hasDom()) throw new LiveClientMissingError({ hook });
|
|
88
|
-
return serverRegistration();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---- useLive ------------------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
/** The query's input, or a thunk returning it. The thunk is read once — see `useLive`. */
|
|
94
|
-
export type LiveInput = JsonValue | (() => JsonValue);
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A callable result set: `feed()` are the rows, `feed.state()` / `feed.cursor()` /
|
|
98
|
-
* `feed.unsubscribe()` are the rest of the `LiveHandle` hanging off it. It is also `Disposable`
|
|
99
|
-
* (inherited from `LiveHandle`), so `using feed = useLive(...)` unsubscribes on scope exit.
|
|
100
|
-
*
|
|
101
|
-
* `R` is only constrained to `object`: on the wire every row is a `Row`, but a hook bound to a
|
|
102
|
-
* declared query (`query-hook.ts`) answers in that query's own row type, which is whatever its
|
|
103
|
-
* `sql` returns. The three members hanging off the accessor are the same for every `R`.
|
|
104
|
-
*/
|
|
105
|
-
export type LiveRows<R extends object = Row> = (() => readonly R[]) & Omit<LiveHandle, 'rows'>;
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Subscribe to a live query. `query` is anything carrying a `name`, which a `@ultimat3/query`
|
|
109
|
-
* `Query` satisfies structurally — realtime is tier 3, so it names the shape rather than importing
|
|
110
|
-
* the package sideways.
|
|
111
|
-
*
|
|
112
|
-
* A thunk `input` is read **once**, at subscribe time: tier 3 has no reactive runtime of its own,
|
|
113
|
-
* so nothing re-runs it when its dependencies change. Changing input means a new subscription.
|
|
114
|
-
* The caller owns `unsubscribe` — nothing here disposes on unmount, because nothing here knows
|
|
115
|
-
* what a mount is. `using` works too, when the caller does have a scope to hang it on.
|
|
116
|
-
*/
|
|
117
|
-
export function useLive<R extends Row = Row>(query: LiveQueryRef, input: LiveInput): LiveRows<R> {
|
|
118
|
-
const handle = live('useLive').client.useLive<R>(
|
|
119
|
-
query,
|
|
120
|
-
typeof input === 'function' ? input() : input,
|
|
121
|
-
);
|
|
122
|
-
return Object.assign((): readonly R[] => handle.rows(), {
|
|
123
|
-
state: handle.state,
|
|
124
|
-
cursor: handle.cursor,
|
|
125
|
-
unsubscribe: handle.unsubscribe,
|
|
126
|
-
[Symbol.dispose]: handle[Symbol.dispose],
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ---- useConnection ------------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
export interface Connection {
|
|
133
|
-
readonly offline: boolean;
|
|
134
|
-
readonly online: boolean;
|
|
135
|
-
/** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
|
|
136
|
-
readonly reconnectAt: number | null;
|
|
137
|
-
/** The buildId the server announced, or `null` while this build is current. */
|
|
138
|
-
readonly updateAvailable: string | null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* The socket, as four booleans and two values. Every member is a **getter**, so a read inside a
|
|
143
|
-
* tracking scope reaches the underlying signal; a snapshot object would freeze the answer at the
|
|
144
|
-
* moment the component rendered and never say "offline" again.
|
|
145
|
-
*/
|
|
146
|
-
export function useConnection(): Connection {
|
|
147
|
-
const client = live('useConnection').client;
|
|
148
|
-
return {
|
|
149
|
-
get offline() {
|
|
150
|
-
return !client.connected;
|
|
151
|
-
},
|
|
152
|
-
get online() {
|
|
153
|
-
return client.connected;
|
|
154
|
-
},
|
|
155
|
-
get reconnectAt() {
|
|
156
|
-
return client.reconnectAt();
|
|
157
|
-
},
|
|
158
|
-
get updateAvailable() {
|
|
159
|
-
return client.appUpdateAvailable();
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---- useMutation --------------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Both spellings of a conflict strategy: realtime's `custom()` (`{ kind: 'custom' }`, merging rows)
|
|
168
|
-
* and `@ultimat3/action`'s (`{ strategy: 'custom' }`, merging parsed outputs).
|
|
169
|
-
*/
|
|
170
|
-
export type ConflictLike = ConflictStrategy | { readonly strategy: 'custom' };
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* What a hook needs from a mutator: the name it queues under, and the optimistic twin. A
|
|
174
|
-
* `@ultimat3/action` `Mutator` satisfies it structurally.
|
|
175
|
-
*
|
|
176
|
-
* `local` is declared with **method syntax** on purpose. TypeScript relates method parameters
|
|
177
|
-
* bivariantly, so a `Mutator` whose `local` takes its own parsed input and its own `tx` assigns
|
|
178
|
-
* here with no cast at the call site; written as a function-typed property, `strictFunctionTypes`
|
|
179
|
-
* would reject exactly that mutator. The parameters are `unknown` because this layer never reads
|
|
180
|
-
* them — it binds them and hands the closure to the client.
|
|
181
|
-
*/
|
|
182
|
-
export interface MutatorLike {
|
|
183
|
-
readonly name: string;
|
|
184
|
-
local?(tx: unknown, input: unknown): void;
|
|
185
|
-
readonly entity?: string;
|
|
186
|
-
readonly conflict?: ConflictLike;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** A callable mutator with its own queue depth attached. */
|
|
190
|
-
export type Mutate = ((input: JsonValue) => Promise<void>) & {
|
|
191
|
-
/** Queued mutations of this mutator. Always `0` at tier 2, where nothing is queued. */
|
|
192
|
-
readonly pending: number;
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Call a mutator: optimistic twin, rebase entry, durable queue, drain — all of it inside
|
|
197
|
-
* `LiveClient.mutate`. `pending` is a getter, so it stays reactive for as long as the component
|
|
198
|
-
* reads it; it refreshes on every call routed through this hook and on `useMutationQueue().drain`.
|
|
199
|
-
*/
|
|
200
|
-
export function useMutation(mutator: MutatorLike): Mutate {
|
|
201
|
-
const state = live('useMutation');
|
|
202
|
-
const ref = mutatorRef(mutator);
|
|
203
|
-
const call = async (input: JsonValue): Promise<void> => {
|
|
204
|
-
await state.client.mutate(ref, input);
|
|
205
|
-
state.bump();
|
|
206
|
-
};
|
|
207
|
-
Object.defineProperty(call, 'pending', {
|
|
208
|
-
get: (): number => {
|
|
209
|
-
state.version();
|
|
210
|
-
const queue = state.client.queue;
|
|
211
|
-
return queue === undefined
|
|
212
|
-
? 0
|
|
213
|
-
: queue.pending().filter((mutation) => mutation.name === mutator.name).length;
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
// `defineProperty` cannot widen a function type, so the assembled shape is asserted once, here.
|
|
217
|
-
return call as Mutate;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ---- useMutationQueue ---------------------------------------------------------------------------
|
|
221
|
-
|
|
222
|
-
export interface MutationQueue {
|
|
223
|
-
/** Queued and not yet acknowledged, across every mutator. */
|
|
224
|
-
readonly pending: number;
|
|
225
|
-
/** Terminally failed — a policy denial or a validation error, kept for the UI, never retried. */
|
|
226
|
-
readonly failed: number;
|
|
227
|
-
drain(): Promise<void>;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** The whole durable queue, as two counts and the one command that empties it. */
|
|
231
|
-
export function useMutationQueue(): MutationQueue {
|
|
232
|
-
const state = live('useMutationQueue');
|
|
233
|
-
return {
|
|
234
|
-
get pending() {
|
|
235
|
-
state.version();
|
|
236
|
-
return state.client.queue?.pending().length ?? 0;
|
|
237
|
-
},
|
|
238
|
-
get failed() {
|
|
239
|
-
state.version();
|
|
240
|
-
const all = state.client.queue?.all() ?? [];
|
|
241
|
-
return all.filter((mutation) => mutation.status === 'failed').length;
|
|
242
|
-
},
|
|
243
|
-
drain: async () => {
|
|
244
|
-
await state.client.drain();
|
|
245
|
-
state.bump();
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** The hook's mutator, as the client's. Only the twin needs binding; the rest is carried through. */
|
|
251
|
-
function mutatorRef(mutator: MutatorLike): MutatorRef {
|
|
252
|
-
const conflict = replayable(mutator.conflict);
|
|
253
|
-
return {
|
|
254
|
-
name: mutator.name,
|
|
255
|
-
// Called back through the mutator rather than through a hoisted reference: `local` may be
|
|
256
|
-
// written as a method, and an unbound method loses the receiver its body could read.
|
|
257
|
-
...(mutator.local === undefined
|
|
258
|
-
? {}
|
|
259
|
-
: {
|
|
260
|
-
local: (tx: LocalTx, input: JsonValue) => {
|
|
261
|
-
mutator.local?.(tx, input);
|
|
262
|
-
},
|
|
263
|
-
}),
|
|
264
|
-
...(mutator.entity === undefined ? {} : { entity: mutator.entity }),
|
|
265
|
-
...(conflict === undefined ? {} : { conflict }),
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* `@ultimat3/action`'s `custom(merge)` merges parsed *outputs*; realtime's merges *rows*. Only the
|
|
271
|
-
* second is a `CustomMerge` `reconcile` can replay, so the other spelling is dropped rather than
|
|
272
|
-
* handed to a rebase that would call it with the wrong argument — the log then takes its default.
|
|
273
|
-
*/
|
|
274
|
-
function replayable(conflict: ConflictLike | undefined): ConflictStrategy | undefined {
|
|
275
|
-
if (conflict === undefined || typeof conflict === 'string') return conflict;
|
|
276
|
-
return 'kind' in conflict ? conflict : undefined;
|
|
277
|
-
}
|
package/src/identity-map.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// One row VALUE per `(scope, id)` for the whole client — the identity map the thesis takes from
|
|
2
|
-
// Ember Data. Two components holding two copies of one row is the bug it makes unrepresentable:
|
|
3
|
-
// a live query's window and the tier-3 local store are both projections over this one map, so a
|
|
4
|
-
// write through either is the same row for both. Membership and order live in the projections.
|
|
5
|
-
|
|
6
|
-
import type { JsonObject, JsonValue, Row } from './json';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The entity's table — the same name `ChangeEvent.entity`, `tx.<table>` and a mutator's `entity`
|
|
10
|
-
* already use, which is what makes the live path and the local store address one row identically.
|
|
11
|
-
* A subscription whose entity the server did not name gets a private `?query:<name>` scope: no
|
|
12
|
-
* sharing is worse than sharing two different entities that happen to spell one id the same way.
|
|
13
|
-
*/
|
|
14
|
-
export type RowScope = string;
|
|
15
|
-
|
|
16
|
-
/** `scope`+NUL+`id`. NUL cannot occur in an entity name, so the join is unambiguous. */
|
|
17
|
-
export type RowKey = string;
|
|
18
|
-
|
|
19
|
-
export function rowKey(scope: RowScope, id: string): RowKey {
|
|
20
|
-
return `${scope}\u0000${id}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The scope a subscription uses until the server names its entity. `?` starts no entity name. */
|
|
24
|
-
export function privateScope(queryName: string): RowScope {
|
|
25
|
-
return `?query:${queryName}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type IdentityListener = (changed: ReadonlySet<RowKey>) => void;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The map. Values are immutable: every write produces a NEW row object, because a projection over
|
|
32
|
-
* this map hands its rows to a signal and a mutated-in-place row is a render that never happens.
|
|
33
|
-
* "One row per id" therefore means one *current value* per id, referenced by every holder at once.
|
|
34
|
-
*/
|
|
35
|
-
export class IdentityMap {
|
|
36
|
-
readonly #values = new Map<RowKey, Row>();
|
|
37
|
-
/** How many projections hold each key. A row nobody holds is dropped — a window is not a leak. */
|
|
38
|
-
readonly #holds = new Map<RowKey, number>();
|
|
39
|
-
readonly #listeners = new Set<IdentityListener>();
|
|
40
|
-
/** Non-null while a batch is open; every write inside one notifies exactly once, at the end. */
|
|
41
|
-
#changed: Set<RowKey> | null = null;
|
|
42
|
-
|
|
43
|
-
peek(scope: RowScope, id: string): Row | undefined {
|
|
44
|
-
return this.#values.get(rowKey(scope, id));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Whole-row write: the value becomes exactly this row. `insert` and a rollback's undo use it. */
|
|
48
|
-
set(scope: RowScope, row: Row): Row {
|
|
49
|
-
const key = rowKey(scope, row.id);
|
|
50
|
-
const current = this.#values.get(key);
|
|
51
|
-
if (current === row) return row;
|
|
52
|
-
this.#values.set(key, row);
|
|
53
|
-
this.#touch(key);
|
|
54
|
-
return row;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Merge changed columns onto the current value. This is the write every server patch, every
|
|
59
|
-
* snapshot row and every `upsert` takes: a projection that selected fewer columns must not blank
|
|
60
|
-
* the columns another projection is rendering, so a write never removes a key. `undefined` in a
|
|
61
|
-
* patch means "leave it alone", exactly as the local store's contract already says.
|
|
62
|
-
*/
|
|
63
|
-
merge(
|
|
64
|
-
scope: RowScope,
|
|
65
|
-
id: string,
|
|
66
|
-
columns: Readonly<Record<string, JsonValue | undefined>>,
|
|
67
|
-
): Row {
|
|
68
|
-
const key = rowKey(scope, id);
|
|
69
|
-
const current = this.#values.get(key);
|
|
70
|
-
const next: JsonObject = { ...current };
|
|
71
|
-
let changed = current === undefined;
|
|
72
|
-
for (const [column, value] of Object.entries(columns)) {
|
|
73
|
-
if (value === undefined || column === 'id') continue;
|
|
74
|
-
if (current === undefined || current[column] !== value) changed = true;
|
|
75
|
-
next[column] = value;
|
|
76
|
-
}
|
|
77
|
-
const row: Row = { ...next, id };
|
|
78
|
-
// A patch that changed nothing must not re-emit: a no-op write re-rendering every holder is
|
|
79
|
-
// how a live query becomes the most expensive thing on the page.
|
|
80
|
-
if (!changed && current !== undefined) return current;
|
|
81
|
-
this.#values.set(key, row);
|
|
82
|
-
this.#touch(key);
|
|
83
|
-
return row;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
retain(scope: RowScope, id: string): void {
|
|
87
|
-
const key = rowKey(scope, id);
|
|
88
|
-
this.#holds.set(key, (this.#holds.get(key) ?? 0) + 1);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The last holder leaving drops the value: an infinite scroll must not retain every row it saw. */
|
|
92
|
-
release(scope: RowScope, id: string): void {
|
|
93
|
-
const key = rowKey(scope, id);
|
|
94
|
-
const holds = this.#holds.get(key);
|
|
95
|
-
if (holds === undefined) return;
|
|
96
|
-
if (holds > 1) {
|
|
97
|
-
this.#holds.set(key, holds - 1);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
this.#holds.delete(key);
|
|
101
|
-
if (this.#values.delete(key)) this.#touch(key);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Every write inside `fn` collapses into one notification — one patch frame, one render. */
|
|
105
|
-
batch<T>(fn: () => T): T {
|
|
106
|
-
if (this.#changed !== null) return fn();
|
|
107
|
-
const collected = new Set<RowKey>();
|
|
108
|
-
this.#changed = collected;
|
|
109
|
-
try {
|
|
110
|
-
return fn();
|
|
111
|
-
} finally {
|
|
112
|
-
this.#changed = null;
|
|
113
|
-
if (collected.size > 0) this.#notify(collected);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
subscribe(listener: IdentityListener): () => void {
|
|
118
|
-
this.#listeners.add(listener);
|
|
119
|
-
return () => {
|
|
120
|
-
this.#listeners.delete(listener);
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Held keys. Tests assert on it; nothing in the client branches on a count. */
|
|
125
|
-
get size(): number {
|
|
126
|
-
return this.#values.size;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
#touch(key: RowKey): void {
|
|
130
|
-
const batch = this.#changed;
|
|
131
|
-
if (batch !== null) {
|
|
132
|
-
batch.add(key);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
this.#notify(new Set([key]));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#notify(changed: ReadonlySet<RowKey>): void {
|
|
139
|
-
for (const listener of this.#listeners) listener(changed);
|
|
140
|
-
}
|
|
141
|
-
}
|
package/src/local-store.ts
DELETED
|
@@ -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
|
-
}
|