@ultimat3/realtime 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's durable client store: persisted records and the one outbox, in IndexedDB, every entry
|
|
3
|
+
* keyed by the principal it belongs to so one principal's cache can never restore into another's.
|
|
4
|
+
* IndexedDB blocked (a private window, storage disabled) falls back to memory with ONE warning,
|
|
5
|
+
* `X_LOCAL_STORE_UNAVAILABLE` — a page never breaks because it cannot remember.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
|
|
9
|
+
import { isJsonObject, renderThrowable, type UltimateError } from '@ultimat3/core/page';
|
|
10
|
+
import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike } from './idb-types';
|
|
11
|
+
import type { QueueChange, QueuedMutation, QueueState } from './offline-queue';
|
|
12
|
+
import { LocalStoreUnavailableError } from './page-errors';
|
|
13
|
+
|
|
14
|
+
/** One persisted row, by record type and record key. */
|
|
15
|
+
export interface PersistedRow {
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly row: Row;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocalStore {
|
|
22
|
+
readonly kind: 'indexeddb' | 'memory';
|
|
23
|
+
/** Every persisted row of one scope: record type -> record key -> row. */
|
|
24
|
+
rows(scope: string): Promise<ReadonlyMap<string, RecordRows>>;
|
|
25
|
+
write(
|
|
26
|
+
scope: string,
|
|
27
|
+
puts: readonly PersistedRow[],
|
|
28
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
29
|
+
): Promise<void>;
|
|
30
|
+
queue(scope: string): Promise<QueueState | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* One change to one scope's outbox, BY KEY (`QueueChange`). It was `saveQueue(scope, state)`,
|
|
33
|
+
* a whole-queue save — and two tabs of one user each saved their own copy, so the last save won
|
|
34
|
+
* and the other tab's queued write was erased.
|
|
35
|
+
*/
|
|
36
|
+
writeQueue(scope: string, change: QueueChange): Promise<void>;
|
|
37
|
+
/** Everything of one scope — its rows AND its outbox. Sign-out, or any principal change. */
|
|
38
|
+
wipe(scope: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Everything of every scope but `keep` — rows and outboxes. The page boot's answer to a sign-out
|
|
41
|
+
* by full navigation, which never calls `rescope()` and so never reaches `wipe`.
|
|
42
|
+
*/
|
|
43
|
+
wipeOthers(keep: string): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The storage key of a principal, or `undefined` for an UNSCOPED page (rendered for nobody):
|
|
48
|
+
* nothing is persisted there, because there is no principal to key it by. Prefixed so a principal
|
|
49
|
+
* spelled `anon` can never share the anonymous visitor's rows.
|
|
50
|
+
*/
|
|
51
|
+
export function scopeKey(principal: ClientScope['principal']): string | undefined {
|
|
52
|
+
if (principal === undefined) return undefined;
|
|
53
|
+
return principal === null ? 'anon' : `p:${principal}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RECORDS = 'records';
|
|
57
|
+
const OUTBOX = 'outbox';
|
|
58
|
+
/** JSON, never a joined string: a principal is opaque and may hold any separator. */
|
|
59
|
+
const rowKey = (scope: string, type: string, key: string): string =>
|
|
60
|
+
JSON.stringify([scope, type, key]);
|
|
61
|
+
/** One queued mutation, and one scope's sequence floor — the outbox's two record shapes. */
|
|
62
|
+
const mutationKey = (scope: string, key: string): string => JSON.stringify([scope, 'm', key]);
|
|
63
|
+
const seqSlot = (scope: string): string => JSON.stringify([scope, 'seq']);
|
|
64
|
+
|
|
65
|
+
/** The scope an outbox key belongs to: `[scope, …]`, or a pre-22.0.0 whole-queue record `scope`. */
|
|
66
|
+
function outboxScopeOf(raw: unknown): string | undefined {
|
|
67
|
+
if (typeof raw !== 'string') return undefined;
|
|
68
|
+
if (!raw.startsWith('[')) return raw;
|
|
69
|
+
const parts: unknown = JSON.parse(raw);
|
|
70
|
+
return Array.isArray(parts) && typeof parts[0] === 'string' ? parts[0] : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const byQueueOrder = (a: QueuedMutation, b: QueuedMutation): number =>
|
|
74
|
+
a.seq - b.seq || a.enqueuedAt - b.enqueuedAt || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
75
|
+
|
|
76
|
+
/** Memory: tests, SSR, and the fallback when IndexedDB is unavailable. */
|
|
77
|
+
export class MemoryLocalStore implements LocalStore {
|
|
78
|
+
readonly kind = 'memory';
|
|
79
|
+
readonly #rows = new Map<string, PersistedRow & { readonly scope: string }>();
|
|
80
|
+
readonly #queues = new Map<string, { mutations: Map<string, QueuedMutation>; nextSeq: number }>();
|
|
81
|
+
|
|
82
|
+
async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
|
|
83
|
+
return group([...this.#rows.values()].filter((entry) => entry.scope === scope));
|
|
84
|
+
}
|
|
85
|
+
async write(
|
|
86
|
+
scope: string,
|
|
87
|
+
puts: readonly PersistedRow[],
|
|
88
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
for (const { type, key } of deletes) this.#rows.delete(rowKey(scope, type, key));
|
|
91
|
+
for (const put of puts) this.#rows.set(rowKey(scope, put.type, put.key), { ...put, scope });
|
|
92
|
+
}
|
|
93
|
+
async queue(scope: string): Promise<QueueState | undefined> {
|
|
94
|
+
const held = this.#queues.get(scope);
|
|
95
|
+
if (held === undefined) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
mutations: structuredClone([...held.mutations.values()]).sort(byQueueOrder),
|
|
98
|
+
nextSeq: held.nextSeq,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async writeQueue(scope: string, change: QueueChange): Promise<void> {
|
|
102
|
+
const held = this.#queues.get(scope) ?? { mutations: new Map(), nextSeq: 1 };
|
|
103
|
+
for (const key of change.deletes) held.mutations.delete(key);
|
|
104
|
+
for (const put of change.puts) held.mutations.set(put.key, structuredClone(put));
|
|
105
|
+
held.nextSeq = Math.max(held.nextSeq, change.nextSeq);
|
|
106
|
+
this.#queues.set(scope, held);
|
|
107
|
+
}
|
|
108
|
+
async wipe(scope: string): Promise<void> {
|
|
109
|
+
for (const [key, entry] of this.#rows) if (entry.scope === scope) this.#rows.delete(key);
|
|
110
|
+
this.#queues.delete(scope);
|
|
111
|
+
}
|
|
112
|
+
async wipeOthers(keep: string): Promise<void> {
|
|
113
|
+
for (const [key, entry] of this.#rows) if (entry.scope !== keep) this.#rows.delete(key);
|
|
114
|
+
for (const scope of [...this.#queues.keys()]) if (scope !== keep) this.#queues.delete(scope);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class IdbLocalStore implements LocalStore {
|
|
119
|
+
readonly kind = 'indexeddb';
|
|
120
|
+
constructor(private readonly db: IdbDatabaseLike) {}
|
|
121
|
+
|
|
122
|
+
async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
|
|
123
|
+
const tx = this.db.transaction(RECORDS, 'readonly');
|
|
124
|
+
const store = tx.objectStore(RECORDS);
|
|
125
|
+
const [keys, values] = await Promise.all([answer(store.getAllKeys()), answer(store.getAll())]);
|
|
126
|
+
const found: PersistedRow[] = [];
|
|
127
|
+
keys.forEach((raw, index) => {
|
|
128
|
+
const parts: unknown = typeof raw === 'string' ? JSON.parse(raw) : undefined;
|
|
129
|
+
const row = values[index];
|
|
130
|
+
if (!Array.isArray(parts) || parts[0] !== scope || !isJsonObject(row)) return;
|
|
131
|
+
found.push({ type: String(parts[1]), key: String(parts[2]), row });
|
|
132
|
+
});
|
|
133
|
+
return group(found);
|
|
134
|
+
}
|
|
135
|
+
async write(
|
|
136
|
+
scope: string,
|
|
137
|
+
puts: readonly PersistedRow[],
|
|
138
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const tx = this.db.transaction(RECORDS, 'readwrite');
|
|
141
|
+
const store = tx.objectStore(RECORDS);
|
|
142
|
+
for (const { type, key } of deletes) store.delete(rowKey(scope, type, key));
|
|
143
|
+
for (const put of puts) store.put(put.row, rowKey(scope, put.type, put.key));
|
|
144
|
+
await done(tx);
|
|
145
|
+
}
|
|
146
|
+
async queue(scope: string): Promise<QueueState | undefined> {
|
|
147
|
+
// Read-WRITE: a pre-22.0.0 whole-queue record for this scope is converted in the same
|
|
148
|
+
// transaction, so an upgrade keeps the writes a user queued on the previous version.
|
|
149
|
+
const tx = this.db.transaction(OUTBOX, 'readwrite');
|
|
150
|
+
const store = tx.objectStore(OUTBOX);
|
|
151
|
+
// Both issued before either is awaited, and awaited one by one: the conversion below writes in
|
|
152
|
+
// this transaction, and it must still be open when the reads land.
|
|
153
|
+
const asked = [answer(store.getAllKeys()), answer(store.getAll())] as const;
|
|
154
|
+
const keys = await asked[0];
|
|
155
|
+
const values = await asked[1];
|
|
156
|
+
const mutations = new Map<string, QueuedMutation>();
|
|
157
|
+
let nextSeq = 1;
|
|
158
|
+
let found = false;
|
|
159
|
+
let converted = false;
|
|
160
|
+
keys.forEach((raw, index) => {
|
|
161
|
+
if (outboxScopeOf(raw) !== scope) return;
|
|
162
|
+
found = true;
|
|
163
|
+
const value = values[index];
|
|
164
|
+
if (raw === scope) {
|
|
165
|
+
const legacy = value as QueueState;
|
|
166
|
+
for (const mutation of legacy.mutations) {
|
|
167
|
+
mutations.set(mutation.key, mutation);
|
|
168
|
+
store.put(mutation, mutationKey(scope, mutation.key));
|
|
169
|
+
}
|
|
170
|
+
nextSeq = Math.max(nextSeq, legacy.nextSeq);
|
|
171
|
+
store.put(nextSeq, seqSlot(scope));
|
|
172
|
+
store.delete(scope);
|
|
173
|
+
converted = true;
|
|
174
|
+
} else if (raw === seqSlot(scope)) {
|
|
175
|
+
nextSeq = Math.max(nextSeq, typeof value === 'number' ? value : 1);
|
|
176
|
+
} else {
|
|
177
|
+
const mutation = value as QueuedMutation;
|
|
178
|
+
mutations.set(mutation.key, mutation);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
// Awaited only when something was written: a transaction that issued nothing after its reads
|
|
182
|
+
// has already completed, and a listener attached now would wait for an event that is gone.
|
|
183
|
+
if (converted) await done(tx);
|
|
184
|
+
return found ? { mutations: [...mutations.values()].sort(byQueueOrder), nextSeq } : undefined;
|
|
185
|
+
}
|
|
186
|
+
async writeQueue(scope: string, change: QueueChange): Promise<void> {
|
|
187
|
+
const tx = this.db.transaction(OUTBOX, 'readwrite');
|
|
188
|
+
const store = tx.objectStore(OUTBOX);
|
|
189
|
+
const floor = await answer(store.get(seqSlot(scope)));
|
|
190
|
+
for (const key of change.deletes) store.delete(mutationKey(scope, key));
|
|
191
|
+
for (const put of change.puts) store.put(put, mutationKey(scope, put.key));
|
|
192
|
+
store.put(Math.max(typeof floor === 'number' ? floor : 1, change.nextSeq), seqSlot(scope));
|
|
193
|
+
await done(tx);
|
|
194
|
+
}
|
|
195
|
+
async wipe(scope: string): Promise<void> {
|
|
196
|
+
const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
|
|
197
|
+
const records = tx.objectStore(RECORDS);
|
|
198
|
+
const keys = await answer(records.getAllKeys());
|
|
199
|
+
for (const raw of keys) {
|
|
200
|
+
if (typeof raw !== 'string') continue;
|
|
201
|
+
const parts: unknown = JSON.parse(raw);
|
|
202
|
+
if (Array.isArray(parts) && parts[0] === scope) records.delete(raw);
|
|
203
|
+
}
|
|
204
|
+
const outbox = tx.objectStore(OUTBOX);
|
|
205
|
+
for (const raw of await answer(outbox.getAllKeys())) {
|
|
206
|
+
if (outboxScopeOf(raw) === scope && typeof raw === 'string') outbox.delete(raw);
|
|
207
|
+
}
|
|
208
|
+
await done(tx);
|
|
209
|
+
}
|
|
210
|
+
async wipeOthers(keep: string): Promise<void> {
|
|
211
|
+
const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
|
|
212
|
+
const records = tx.objectStore(RECORDS);
|
|
213
|
+
const outbox = tx.objectStore(OUTBOX);
|
|
214
|
+
const [rowKeys, queueKeys] = await Promise.all([
|
|
215
|
+
answer(records.getAllKeys()),
|
|
216
|
+
answer(outbox.getAllKeys()),
|
|
217
|
+
]);
|
|
218
|
+
for (const raw of rowKeys) {
|
|
219
|
+
if (typeof raw !== 'string') continue;
|
|
220
|
+
const parts: unknown = JSON.parse(raw);
|
|
221
|
+
if (Array.isArray(parts) && parts[0] !== keep) records.delete(raw);
|
|
222
|
+
}
|
|
223
|
+
for (const raw of queueKeys) {
|
|
224
|
+
const scope = outboxScopeOf(raw);
|
|
225
|
+
if (typeof raw === 'string' && scope !== undefined && scope !== keep) outbox.delete(raw);
|
|
226
|
+
}
|
|
227
|
+
await done(tx);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface OpenLocalStoreOptions {
|
|
232
|
+
/** Default `globalThis.indexedDB`, read at call time. */
|
|
233
|
+
readonly indexedDB?: IdbFactoryLike | undefined;
|
|
234
|
+
/** Where the one fallback warning goes. Default `console.warn`. */
|
|
235
|
+
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
236
|
+
readonly name?: string | undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** IndexedDB when the page has it and it opens; memory, warned once, when it does not. */
|
|
240
|
+
export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promise<LocalStore> {
|
|
241
|
+
// The browser's `IDBFactory` IS this shape at runtime; its DOM typings (event-typed handlers)
|
|
242
|
+
// are wider than the slice `idb-types.ts` declares, so the ambient value is narrowed once here.
|
|
243
|
+
const ambient: unknown = Reflect.get(globalThis, 'indexedDB');
|
|
244
|
+
const factory = options.indexedDB ?? (ambient as IdbFactoryLike | undefined);
|
|
245
|
+
const warn = options.warn ?? ((error: UltimateError) => console.warn(error));
|
|
246
|
+
if (factory === undefined) {
|
|
247
|
+
warn(new LocalStoreUnavailableError({ reason: 'this runtime has no indexedDB' }));
|
|
248
|
+
return new MemoryLocalStore();
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return new IdbLocalStore(await openDatabase(factory, options.name ?? 'ultimate-client'));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
warn(
|
|
254
|
+
new LocalStoreUnavailableError({
|
|
255
|
+
reason: `indexedDB.open failed: ${renderThrowable(error)}`,
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
return new MemoryLocalStore();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function openDatabase(factory: IdbFactoryLike, name: string): Promise<IdbDatabaseLike> {
|
|
263
|
+
return new Promise((resolve, reject) => {
|
|
264
|
+
const request = factory.open(name, 1);
|
|
265
|
+
request.onupgradeneeded = (): void => {
|
|
266
|
+
const db = request.result;
|
|
267
|
+
if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
|
|
268
|
+
if (!db.objectStoreNames.contains(OUTBOX)) db.createObjectStore(OUTBOX);
|
|
269
|
+
};
|
|
270
|
+
request.onsuccess = (): void => resolve(request.result);
|
|
271
|
+
request.onerror = (): void => reject(request.error);
|
|
272
|
+
request.onblocked = (): void => reject(request.error ?? 'blocked by another open tab');
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function answer<T>(request: IdbRequestLike<T>): Promise<T> {
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
request.onsuccess = (): void => resolve(request.result);
|
|
279
|
+
request.onerror = (): void => reject(request.error);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function done(tx: {
|
|
284
|
+
oncomplete: (() => void) | null;
|
|
285
|
+
onerror: (() => void) | null;
|
|
286
|
+
onabort: (() => void) | null;
|
|
287
|
+
error: unknown;
|
|
288
|
+
}): Promise<void> {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
tx.oncomplete = (): void => resolve();
|
|
291
|
+
tx.onerror = (): void => reject(tx.error);
|
|
292
|
+
// A quota refusal ABORTS the transaction and fires nothing else, so a store that listened only
|
|
293
|
+
// for `complete` and `error` left `write`, `writeQueue`, `flush` and `enqueue` pending forever.
|
|
294
|
+
tx.onabort = (): void =>
|
|
295
|
+
reject(tx.error ?? new DOMException('the transaction was aborted', 'AbortError'));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function group(entries: readonly PersistedRow[]): ReadonlyMap<string, RecordRows> {
|
|
300
|
+
const out = new Map<string, Record<string, Row>>();
|
|
301
|
+
for (const { type, key, row } of entries) {
|
|
302
|
+
const rows = out.get(type) ?? (Object.create(null) as Record<string, Row>);
|
|
303
|
+
Object.defineProperty(rows, key, { value: row, enumerable: true });
|
|
304
|
+
out.set(type, rows);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const PAGE_KEY: unique symbol = Symbol.for('ultimate.local-store');
|
|
310
|
+
type PageHost = { [PAGE_KEY]?: Promise<LocalStore> };
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The page's ONE durable store, opened once per tab whichever island asks first — the persister and
|
|
314
|
+
* the outbox share it, so a wipe on a principal change reaches both. On `globalThis`, like the
|
|
315
|
+
* record store, because every island bundle carries its own copy of this module.
|
|
316
|
+
*/
|
|
317
|
+
export function pageLocalStore(): Promise<LocalStore> {
|
|
318
|
+
const host = globalThis as PageHost;
|
|
319
|
+
const existing = host[PAGE_KEY];
|
|
320
|
+
if (existing !== undefined) return existing;
|
|
321
|
+
const opened = openLocalStore();
|
|
322
|
+
Object.defineProperty(host, PAGE_KEY, { value: opened, configurable: true });
|
|
323
|
+
return opened;
|
|
324
|
+
}
|
package/src/matcher-bridge.ts
CHANGED
|
@@ -61,6 +61,8 @@ export function bridgeChange(
|
|
|
61
61
|
|
|
62
62
|
/** Default derivation, used by matchers that only answer "affected" without describing the delta. */
|
|
63
63
|
export function patchFromChange(change: ChangeEvent): RowPatch | null {
|
|
64
|
+
// A truncate names no row, so there is no patch — the window is re-read (`live-fanout.ts`).
|
|
65
|
+
if (change.op === 'truncate') return null;
|
|
64
66
|
if (change.op === 'delete') {
|
|
65
67
|
const id = change.before?.id;
|
|
66
68
|
return id === undefined ? null : { op: 'delete', id, row: null, lsn: change.lsn };
|
|
@@ -81,6 +83,9 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
|
|
|
81
83
|
return {
|
|
82
84
|
entities: live.reads,
|
|
83
85
|
match: (change, rows) => {
|
|
86
|
+
// Every row of a relation this query reads is gone: the window cannot be patched, only
|
|
87
|
+
// replaced. `refill` is the matcher's word for exactly that.
|
|
88
|
+
if (change.op === 'truncate') return { patches: [], refill: true };
|
|
84
89
|
const row = change.after ?? change.before;
|
|
85
90
|
if (!row) return NO_CHANGE;
|
|
86
91
|
const patches = match<Row>(live.name, live.shape, rows, {
|
package/src/nats-fake.ts
CHANGED
|
@@ -337,11 +337,20 @@ export class FakeNatsBroker {
|
|
|
337
337
|
): readonly NatsMessage[] {
|
|
338
338
|
this.#maybeFail(subject);
|
|
339
339
|
const body = bodyOf(payload);
|
|
340
|
-
|
|
340
|
+
// `multi_last` (last per subject) or `next_by_subj` (every message under a filter). Over a
|
|
341
|
+
// history-one KV stream the two answer the same messages, which is what `kvLast` relies on.
|
|
342
|
+
const nextBy = typeof body['next_by_subj'] === 'string' ? [body['next_by_subj']] : [];
|
|
343
|
+
const filters = subject.startsWith(DIRECT_GET)
|
|
344
|
+
? [...stringList(body['multi_last']), ...nextBy]
|
|
345
|
+
: [];
|
|
341
346
|
if (filters.length === 0) throw unavailable(`no responders for ${subject}`);
|
|
342
347
|
const batch = numberOr(body['batch'], DEFAULT_BATCH);
|
|
348
|
+
// `seq` is where a paged read resumes: the server answers in sequence order from there.
|
|
349
|
+
const from = numberOr(body['seq'], 0);
|
|
343
350
|
const matched = this.#current()
|
|
344
351
|
.filter((stored) => filters.some((filter) => subjectMatches(filter, stored.subject)))
|
|
352
|
+
.filter((stored) => stored.seq >= from)
|
|
353
|
+
.sort((a, b) => a.seq - b.seq)
|
|
345
354
|
.slice(0, batch);
|
|
346
355
|
const replies = matched.map((stored) => this.#replyFor(stored));
|
|
347
356
|
// A batch always terminates: `204 EOB` behind results, `404` when the filter matched nothing.
|
package/src/nats-jetstream.ts
CHANGED
|
@@ -181,8 +181,16 @@ export async function kvGet(
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Every current value under a wildcard
|
|
185
|
-
*
|
|
184
|
+
* Every current value under a wildcard. A batch direct read answers at most `batch` messages and
|
|
185
|
+
* then an empty `204 EOB`; a range nobody has written answers `404` and nothing else.
|
|
186
|
+
*
|
|
187
|
+
* PAGED, on the last sequence read, and by `next_by_subj` rather than `multi_last`. It was one
|
|
188
|
+
* `multi_last` batch, and that fails twice past 1,000 members: the batch truncated the set — and
|
|
189
|
+
* `sweep()`, which differences the full set, announced a `leave` for every member past the cut —
|
|
190
|
+
* and a real nats-server (2.11, measured) refuses a `multi_last` matching more than 1,024 subjects
|
|
191
|
+
* outright with `413 Too Many Results`, which the old loop skipped as a marker and returned an
|
|
192
|
+
* EMPTY set. The bucket keeps one message per subject (`max_msgs_per_subject: 1`), so walking every
|
|
193
|
+
* message under the filter IS the last value per key. A page shorter than `batch` is the last.
|
|
186
194
|
*/
|
|
187
195
|
export async function kvLast(
|
|
188
196
|
client: NatsClient,
|
|
@@ -191,19 +199,33 @@ export async function kvLast(
|
|
|
191
199
|
batch = 1_000,
|
|
192
200
|
): Promise<readonly KvRecord[]> {
|
|
193
201
|
const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}`;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
202
|
+
const byKey = new Map<string, KvRecord>();
|
|
203
|
+
let from = 1;
|
|
204
|
+
for (;;) {
|
|
205
|
+
const body = { seq: from, next_by_subj: kvSubject(bucket, filter), batch };
|
|
206
|
+
const replies = await client.requestMany(subject, encoder.encode(JSON.stringify(body)), {
|
|
207
|
+
until: (message) => message.status === STATUS_EOB || message.status === STATUS_NOT_FOUND,
|
|
208
|
+
});
|
|
209
|
+
let last = 0;
|
|
210
|
+
let read = 0;
|
|
211
|
+
for (const reply of replies) {
|
|
212
|
+
// The terminators are filtered by `until`. Any OTHER status — a `408` timeout, a `503` — is
|
|
213
|
+
// a batch that did not finish, and reading it as the whole set is the truncation above.
|
|
214
|
+
if (reply.status !== 0) {
|
|
215
|
+
throw new TransportUnavailableError({
|
|
216
|
+
transport: 'nats',
|
|
217
|
+
reason: `${subject} answered status ${reply.status} mid-batch, so the set read was incomplete`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
read += 1;
|
|
221
|
+
const seq = Number.parseInt(reply.header('Nats-Sequence') ?? '', 10);
|
|
222
|
+
if (Number.isSafeInteger(seq) && seq > last) last = seq;
|
|
223
|
+
const record = recordOf(reply, bucket);
|
|
224
|
+
if (record) byKey.set(record.key, record);
|
|
225
|
+
}
|
|
226
|
+
if (read < batch || last === 0) return [...byKey.values()];
|
|
227
|
+
from = last + 1;
|
|
205
228
|
}
|
|
206
|
-
return records;
|
|
207
229
|
}
|
|
208
230
|
|
|
209
231
|
/** A KV write is a publish that waits for JetStream's ack — a lost put must not read as stored. */
|
package/src/nats-transport.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { parseNatsUrl } from './nats-client';
|
|
|
18
18
|
import { ensureKvBucket } from './nats-jetstream';
|
|
19
19
|
import { NatsKvSet } from './nats-kv';
|
|
20
20
|
import { openNatsClient } from './nats-lib-client';
|
|
21
|
-
import { type BackoffPolicy,
|
|
21
|
+
import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
|
|
22
22
|
|
|
23
23
|
const encoder = new TextEncoder();
|
|
24
24
|
const decoder = new TextDecoder();
|
|
@@ -156,7 +156,7 @@ export class NatsTransport implements Transport {
|
|
|
156
156
|
maxReconnectAttempts: this.#attempts,
|
|
157
157
|
// The library retries; the spread is ours, so a cluster restart does not bring every node
|
|
158
158
|
// back on the same millisecond.
|
|
159
|
-
reconnectDelay: () =>
|
|
159
|
+
reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
|
|
160
160
|
onError: (error) => this.#report(error, this.name),
|
|
161
161
|
onReconnect: () => this.#recovered(),
|
|
162
162
|
});
|