@ultimat3/realtime 24.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// A socket whose actor changed mid-connection: every subscription it holds re-decided under the
|
|
2
|
+
// new actor, re-seated onto the new tenant's window, and — new principal's cap first — refused
|
|
3
|
+
// rather than carried past a ceiling. Split from `live-query.ts` at its size ceiling: the registry
|
|
4
|
+
// owns the entry table, this owns what a re-authorization does to one socket's subscriptions.
|
|
5
|
+
|
|
6
|
+
import { queryHash } from '@ultimat3/query';
|
|
7
|
+
import { isPolicyDenial } from './errors';
|
|
8
|
+
import type { LiveSubscription } from './live-contract';
|
|
9
|
+
import { refuseSubscription } from './live-refusal';
|
|
10
|
+
import type { SubscribeArgs } from './live-spend';
|
|
11
|
+
import { liveTenantOf, windowId } from './live-tenant';
|
|
12
|
+
import type { SyncSocket } from './socket';
|
|
13
|
+
import type { SubscriberGate } from './subscriber-gate';
|
|
14
|
+
import type { SubscriptionBook } from './subscription-book';
|
|
15
|
+
import type { Frame } from './sync-protocol';
|
|
16
|
+
|
|
17
|
+
/** What a re-authorization reaches on the registry, and nothing else. */
|
|
18
|
+
export interface ReauthDeps {
|
|
19
|
+
readonly book: SubscriptionBook;
|
|
20
|
+
readonly gate: SubscriberGate;
|
|
21
|
+
unsubscribe(socketId: string, sid: string): void;
|
|
22
|
+
/** Subscribe again under the same sid, uncharged — a re-seat is not a new subscription. */
|
|
23
|
+
resubscribe(args: SubscribeArgs): Promise<{ readonly frame: Frame }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Actor changed mid-connection (login, logout, role change): re-run subscribe-time authz and drop
|
|
28
|
+
* what is no longer allowed. Survivors are marked desynced so the next flush re-snapshots them
|
|
29
|
+
* under the new actor's row policy. Returns the sids that were dropped — a denial and nothing
|
|
30
|
+
* else, so a caller may tell the client "you may no longer see this" and be right.
|
|
31
|
+
*
|
|
32
|
+
* A survivor whose actor now belongs to ANOTHER tenant cannot stay where it is: its window is the
|
|
33
|
+
* old org's. It is re-seated — dropped, subscribed again under the new tenant, and sent that
|
|
34
|
+
* window's snapshot under the same sid — or, when that cannot complete, refused on the socket
|
|
35
|
+
* under the sid (`refuseSubscription`), never kept on the old org's window.
|
|
36
|
+
*/
|
|
37
|
+
export async function reauthorizeSocket(
|
|
38
|
+
deps: ReauthDeps,
|
|
39
|
+
socket: SyncSocket,
|
|
40
|
+
): Promise<readonly string[]> {
|
|
41
|
+
const dropped: string[] = [];
|
|
42
|
+
// The actor changed, so what tenant this socket's subscriptions count against may have too.
|
|
43
|
+
// Told here rather than derived per lookup: the per-tenant cap is an index now, and an index
|
|
44
|
+
// nobody updates is a count that drifts from the book for the rest of the process.
|
|
45
|
+
refuseOverflow(deps, socket);
|
|
46
|
+
deps.book.retenant(socket);
|
|
47
|
+
for (const subscription of deps.book.ofSocket(socket.id)) {
|
|
48
|
+
let failed: { readonly error: unknown } | undefined;
|
|
49
|
+
try {
|
|
50
|
+
await subscription.definition.authorize?.({
|
|
51
|
+
actor: socket.actor,
|
|
52
|
+
input: subscription.input,
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (isPolicyDenial(error)) {
|
|
56
|
+
deps.unsubscribe(socket.id, subscription.sid);
|
|
57
|
+
// Said under the sid, as a refused subscribe is: unsaid, the client kept the rows on
|
|
58
|
+
// screen in state `live` for a subscription this node no longer serves.
|
|
59
|
+
refuseSubscription(socket, subscription.sid, error);
|
|
60
|
+
dropped.push(subscription.sid);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Not a decision — the gate never reached one. Destroying the subscription would report a
|
|
64
|
+
// database timeout as a revoked grant, and a client does not resubscribe to a denial. It
|
|
65
|
+
// survives, desynced: nothing is delivered from the window built under the old actor, and
|
|
66
|
+
// the row gate still decides every row under the new one, from the same policy `authorize`
|
|
67
|
+
// consults. The failure is counted and reported rather than silently absorbed.
|
|
68
|
+
deps.gate.failedAuthorize(
|
|
69
|
+
subscription.qid,
|
|
70
|
+
{ sid: subscription.sid, actor: socket.actor },
|
|
71
|
+
error,
|
|
72
|
+
);
|
|
73
|
+
failed = { error };
|
|
74
|
+
}
|
|
75
|
+
if (await reseat(deps, socket, subscription, failed)) continue;
|
|
76
|
+
socket.markDesynced(subscription.sid);
|
|
77
|
+
}
|
|
78
|
+
return dropped;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Move one subscription onto its actor's CURRENT tenant's window. `false` when it is already
|
|
83
|
+
* there. A re-seat that cannot complete leaves the subscription dropped — never attached to the
|
|
84
|
+
* window of an org its actor has left — and REFUSED on the socket under its sid, the frame a
|
|
85
|
+
* refused subscribe gets, so the client's window renders `failed` instead of going quiet.
|
|
86
|
+
*
|
|
87
|
+
* `failed` is the `authorize` this pass already saw fail: it is not asked again — the store that
|
|
88
|
+
* just timed out is the one `subscribe` would ask — and it is not counted twice.
|
|
89
|
+
*/
|
|
90
|
+
async function reseat(
|
|
91
|
+
deps: ReauthDeps,
|
|
92
|
+
socket: SyncSocket,
|
|
93
|
+
subscription: LiveSubscription,
|
|
94
|
+
failed: { readonly error: unknown } | undefined,
|
|
95
|
+
): Promise<boolean> {
|
|
96
|
+
const { sid, input, definition } = subscription;
|
|
97
|
+
const wanted = windowId(queryHash(definition.name, input), liveTenantOf(socket.actor));
|
|
98
|
+
if (wanted === subscription.qid) return false;
|
|
99
|
+
deps.unsubscribe(socket.id, sid);
|
|
100
|
+
if (failed !== undefined) {
|
|
101
|
+
refuseSubscription(socket, sid, failed.error);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const { frame } = await deps.resubscribe({ socket, name: definition.name, input, sid });
|
|
106
|
+
if (!socket.send(frame)) socket.markDesynced(sid);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
deps.gate.failedAuthorize(wanted, { sid, actor: socket.actor }, error);
|
|
109
|
+
refuseSubscription(socket, sid, error);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The subscriptions the socket's NEW tenant or actor has no room for, refused under their sids
|
|
116
|
+
* before the move — so the move never carries a key past its cap, and filling an address then
|
|
117
|
+
* signing in cannot be repeated to stack one actor past it. The newest go first: the client's
|
|
118
|
+
* oldest windows are the ones it has been rendering longest. Not a denial, so not in `dropped`.
|
|
119
|
+
*/
|
|
120
|
+
function refuseOverflow(deps: ReauthDeps, socket: SyncSocket): void {
|
|
121
|
+
const overflow = deps.book.overflowOnRekey(socket);
|
|
122
|
+
if (overflow === null) return;
|
|
123
|
+
for (const subscription of deps.book.ofSocket(socket.id).slice(-overflow.excess)) {
|
|
124
|
+
deps.unsubscribe(socket.id, subscription.sid);
|
|
125
|
+
refuseSubscription(socket, subscription.sid, overflow.error);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/live-replicator.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// WHAT IS REAL HERE, and it is everything downstream of the decoder: the matcher, the shared window,
|
|
8
8
|
// the per-subscriber `visible` gate, the cursor, the frames. This substitutes for the WAL DECODER
|
|
9
9
|
// and for nothing else — `@ultimat3/entity`'s `setRowObserver` reports what a repository wrote, in
|
|
10
|
-
// this process, and the events are shaped exactly as `
|
|
10
|
+
// this process, and the events are shaped exactly as `PostgresChangeFeed` shapes them.
|
|
11
11
|
//
|
|
12
12
|
// WHAT IS NOT: a write another process made is invisible, because nothing here reads a log. That is
|
|
13
13
|
// the honest bound, and it is why `selectChangeFeed` never picks it — the boot that installs it
|
|
@@ -27,6 +27,7 @@ import type { RowBulkChange, RowChange, RowObserver } from '@ultimat3/entity';
|
|
|
27
27
|
import type { ChangeEvent, ChangeOp } from './changefeed';
|
|
28
28
|
import type { Row } from './json';
|
|
29
29
|
import type { LiveQueryRegistry } from './live-query';
|
|
30
|
+
import { isPublishedTable } from './record-publisher';
|
|
30
31
|
|
|
31
32
|
/** What a caller does with a change nobody could deliver. */
|
|
32
33
|
export interface LiveReplicatorOptions {
|
|
@@ -35,8 +36,13 @@ export interface LiveReplicatorOptions {
|
|
|
35
36
|
* The node's declared channels, fed the same `ChangeEvent` — what a real node's change
|
|
36
37
|
* subscription does beside `registry.deliver` (`sync-node.ts`). Without it a channel's `records`
|
|
37
38
|
* frames never carried a write made under `x dev`, and every other tab stayed on the old row.
|
|
39
|
+
* `invalidate` is the other half, called beside `registry.invalidate` as the node pairs them: a
|
|
40
|
+
* bulk write reaches no `records` frame, so the topics carrying its table must re-read instead.
|
|
38
41
|
*/
|
|
39
|
-
readonly channels?: {
|
|
42
|
+
readonly channels?: {
|
|
43
|
+
deliverChange(change: ChangeEvent): unknown;
|
|
44
|
+
invalidate(entity?: string): unknown;
|
|
45
|
+
};
|
|
40
46
|
/** Tenant column, hoisted out of the row so fanout filters without parsing it. */
|
|
41
47
|
readonly tenantColumn?: string;
|
|
42
48
|
readonly onError?: (error: unknown) => void;
|
|
@@ -80,10 +86,19 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
|
|
|
80
86
|
let position = 0;
|
|
81
87
|
let delivered = 0;
|
|
82
88
|
// One promise chain, because ORDERING is the guarantee the whole pipeline is built on — the same
|
|
83
|
-
// reason `
|
|
89
|
+
// reason `MemoryChangeFeed` serializes its deliveries rather than firing them concurrently.
|
|
84
90
|
let tail: Promise<void> = Promise.resolve();
|
|
85
91
|
let stopped = false;
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The RELATION an entity's rows live in — what `ChangeEvent.table` carries on every producer:
|
|
95
|
+
* the WAL decoder reads it off the Relation message, a channel matches `projection.table`, and a
|
|
96
|
+
* live shape is `from('<table>', …)`. The row observer reports the entity's declared NAME, which
|
|
97
|
+
* is the table only until an entity adopts one (`entity('ledger', { table: 'gl_entries' })`);
|
|
98
|
+
* passed through, such an entity reached no channel, no window and no bulk re-read here.
|
|
99
|
+
*/
|
|
100
|
+
const tableOf = (name: string): string => entity.getEntity(name)?.tableName ?? name;
|
|
101
|
+
|
|
87
102
|
const enqueue = (work: () => Promise<void>): void => {
|
|
88
103
|
tail = tail.then(work).catch((error: unknown) => {
|
|
89
104
|
// Never rethrown into the chain: one failed fanout must not silence every change behind it,
|
|
@@ -100,7 +115,7 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
|
|
|
100
115
|
const row = change.after ?? change.before;
|
|
101
116
|
const orgId = typeof row?.[tenant] === 'string' ? (row[tenant] as string) : null;
|
|
102
117
|
const event: ChangeEvent = {
|
|
103
|
-
|
|
118
|
+
table: tableOf(change.entity),
|
|
104
119
|
// A repository's three row ops are three of the feed's four; a truncate never comes this way.
|
|
105
120
|
op: change.op satisfies ChangeOp,
|
|
106
121
|
before: asRow(change.before),
|
|
@@ -113,12 +128,14 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
|
|
|
113
128
|
at,
|
|
114
129
|
// The keyed write it belongs to, read off the request scope — what the WAL decoder reads
|
|
115
130
|
// off the transaction's opening message — so a channel frame names it here as it would there.
|
|
116
|
-
|
|
131
|
+
write: change.write ?? null,
|
|
117
132
|
};
|
|
118
133
|
enqueue(async () => {
|
|
119
134
|
// Channels first, as the node does: a live query's fanout that throws must not also cost
|
|
120
|
-
// every declared channel the change.
|
|
121
|
-
|
|
135
|
+
// every declared channel the change. Not an entity a `recordPublisher` claims: the app's
|
|
136
|
+
// own publish is its one channel delivery, as in production with no replicator, and both
|
|
137
|
+
// was every insert twice (#682). Asked per change, so a publisher built later counts.
|
|
138
|
+
if (!isPublishedTable(event.table)) options.channels?.deliverChange(event);
|
|
122
139
|
delivered += await registry.deliver(event);
|
|
123
140
|
});
|
|
124
141
|
},
|
|
@@ -132,7 +149,10 @@ export async function startLiveReplicator(options: LiveReplicatorOptions): Promi
|
|
|
132
149
|
*/
|
|
133
150
|
onBulk(change: RowBulkChange): void {
|
|
134
151
|
if (stopped) return;
|
|
135
|
-
|
|
152
|
+
const table = tableOf(change.entity);
|
|
153
|
+
registry.invalidate(table);
|
|
154
|
+
// Channels too, or a tab seated on one held the pre-update rows with nothing to re-read.
|
|
155
|
+
options.channels?.invalidate(table);
|
|
136
156
|
},
|
|
137
157
|
};
|
|
138
158
|
|
package/src/live-resume.ts
CHANGED
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
type ResumeSource,
|
|
12
12
|
resumeFrom,
|
|
13
13
|
} from './cursor';
|
|
14
|
+
import type { RowPatch } from './json';
|
|
14
15
|
import type { SnapshotResult } from './live-contract';
|
|
15
|
-
import { snapshotFrame } from './live-fanout';
|
|
16
|
+
import { type NamedWrite, patchFrame, snapshotFrame } from './live-fanout';
|
|
16
17
|
import { fillWindow, type QueryEntry } from './query-window';
|
|
17
18
|
import type { Subscriber, SubscriberGate } from './subscriber-gate';
|
|
18
|
-
import {
|
|
19
|
+
import type { Frame } from './sync-protocol';
|
|
19
20
|
|
|
20
21
|
export interface ResumeOntoDeps {
|
|
21
22
|
readonly source: ResumeSource;
|
|
@@ -24,6 +25,8 @@ export interface ResumeOntoDeps {
|
|
|
24
25
|
readonly gate: SubscriberGate;
|
|
25
26
|
/** The window, read once for the entry and filtered for THIS subscriber. */
|
|
26
27
|
read(): Promise<SnapshotResult>;
|
|
28
|
+
/** Called before the delta path fills an unread entry — a database read the caller pays for. */
|
|
29
|
+
beforeFill?(): Promise<void>;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -45,16 +48,23 @@ export async function resumeOnto(
|
|
|
45
48
|
snapshot: () => deps.read(),
|
|
46
49
|
});
|
|
47
50
|
if (resumed.kind !== 'delta') {
|
|
51
|
+
// Read after every retained patch, so it holds their writes; `snapshotFrame` names only those
|
|
52
|
+
// behind a row this subscriber was given. Out of the ring's window there is nothing to name,
|
|
53
|
+
// and a write left unnamed settles on its own HTTP answer.
|
|
54
|
+
const named = retainedWrites(deps.source.since(entry.qid, cursor.lsn) ?? []);
|
|
48
55
|
return {
|
|
49
56
|
cursor: resumed.cursor,
|
|
50
|
-
frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor),
|
|
57
|
+
frame: snapshotFrame(entry, who.sid, resumed.rows, resumed.cursor, named),
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
60
|
// The gate decides about whole rows out of the shared window, and an entry nothing has read
|
|
54
61
|
// yet has none — every patch would meet an empty window and be withheld. Filling is
|
|
55
62
|
// conditional on purpose: a restart storm resumes onto entries that already hold a live
|
|
56
63
|
// window, and re-reading per resuming subscriber is the cost a delta resume exists to skip.
|
|
57
|
-
if (entry.lsn === '')
|
|
64
|
+
if (entry.lsn === '') {
|
|
65
|
+
await deps.beforeFill?.();
|
|
66
|
+
await fillWindow(entry);
|
|
67
|
+
}
|
|
58
68
|
// The live entry on purpose: a resume runs outside the lane, so the window under it may
|
|
59
69
|
// have moved on — always forwards, and a row whose grant was revoked in the meantime is
|
|
60
70
|
// one this pass must refuse rather than replay from the state it had at the cursor's lsn.
|
|
@@ -67,8 +77,34 @@ export async function resumeOnto(
|
|
|
67
77
|
// another tenant's row id and the instant it went. The leak that branch closes, re-opened
|
|
68
78
|
// one layer up. `live-fanout.ts` advances over `allowed` for exactly this reason.
|
|
69
79
|
const seated = advance(cursor, patches, resumed.cursor.lsn, now);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
const { bare, writes } = namedWrites(patches);
|
|
81
|
+
return { cursor: seated, frame: patchFrame(who.sid, bare, seated.lsn, writes) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The writes behind the patches this subscriber is sent, each named once, and the patches as the
|
|
86
|
+
* wire spells them. Read off the FILTERED list: a write behind a row the gate withheld is another
|
|
87
|
+
* actor's, and naming it here would tell this page that write happened.
|
|
88
|
+
*/
|
|
89
|
+
function namedWrites(patches: readonly RowPatch[]): {
|
|
90
|
+
readonly bare: readonly RowPatch[];
|
|
91
|
+
readonly writes: readonly string[];
|
|
92
|
+
} {
|
|
93
|
+
const writes = new Set<string>();
|
|
94
|
+
const bare = patches.map((patch) => {
|
|
95
|
+
if (patch.write === undefined) return patch;
|
|
96
|
+
writes.add(patch.write);
|
|
97
|
+
const { write: _named, ...rest } = patch;
|
|
98
|
+
return rest;
|
|
99
|
+
});
|
|
100
|
+
return { bare, writes: [...writes] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Each retained write, by the record key of the row its patch made. */
|
|
104
|
+
function retainedWrites(patches: readonly RowPatch[]): readonly NamedWrite[] {
|
|
105
|
+
const named: NamedWrite[] = [];
|
|
106
|
+
for (const patch of patches) {
|
|
107
|
+
if (patch.write !== undefined) named.push({ write: patch.write, key: patch.key ?? patch.id });
|
|
108
|
+
}
|
|
109
|
+
return named;
|
|
74
110
|
}
|
package/src/live-rows.ts
CHANGED
|
@@ -70,6 +70,7 @@ export class RowWindows {
|
|
|
70
70
|
type: string | null,
|
|
71
71
|
rows: readonly Row[],
|
|
72
72
|
keys?: readonly string[],
|
|
73
|
+
writes: readonly string[] = [],
|
|
73
74
|
): void {
|
|
74
75
|
const next = type ?? registration.type;
|
|
75
76
|
// The server's record key where it sent one; a row's `id` IS its key everywhere else.
|
|
@@ -80,12 +81,27 @@ export class RowWindows {
|
|
|
80
81
|
keyed.map(([id]) => id),
|
|
81
82
|
() => {
|
|
82
83
|
for (const [id, row] of keyed) this.#store.merge(next, id, row);
|
|
84
|
+
// Settled in this batch, as `patch` settles: truth that holds the write, never under it.
|
|
85
|
+
settle(
|
|
86
|
+
this.#store,
|
|
87
|
+
writes,
|
|
88
|
+
keyed.map(([id]) => recordKey(next, id)),
|
|
89
|
+
);
|
|
83
90
|
},
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
94
|
+
/**
|
|
95
|
+
* A patch list: values merged into the store, membership and order folded over the ids. `writes`
|
|
96
|
+
* are the writes the frame names (`PatchFrame.writes`): each this page still holds is settled
|
|
97
|
+
* against the rows merged here, INSIDE the same batch — so no holder ever renders the server's
|
|
98
|
+
* row with that write's twin replayed over it (truth plus overlay: one like counted twice).
|
|
99
|
+
*/
|
|
100
|
+
patch(
|
|
101
|
+
registration: Registration,
|
|
102
|
+
sent: readonly RowPatch[],
|
|
103
|
+
writes: readonly string[] = [],
|
|
104
|
+
): void {
|
|
89
105
|
const type = registration.type;
|
|
90
106
|
// A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
|
|
91
107
|
const patches = sent.map((patch) =>
|
|
@@ -93,10 +109,16 @@ export class RowWindows {
|
|
|
93
109
|
);
|
|
94
110
|
// A `delete` is this window losing the row, never the store losing it: another holder keeps it.
|
|
95
111
|
this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
|
|
112
|
+
// Only a row merged here is truth this frame carried. A `delete` is membership, not the row:
|
|
113
|
+
// a write it names keeps its overlay on that row until the server's next word on it.
|
|
114
|
+
const carried = new Set<RecordKey>();
|
|
96
115
|
for (const patch of patches) {
|
|
97
116
|
if (patch.op === 'delete' || patch.row === null) continue;
|
|
98
|
-
if (held.has(patch.id))
|
|
117
|
+
if (!held.has(patch.id)) continue;
|
|
118
|
+
this.#store.merge(type, patch.id, patch.row);
|
|
119
|
+
carried.add(recordKey(type, patch.id));
|
|
99
120
|
}
|
|
121
|
+
settle(this.#store, writes, carried);
|
|
100
122
|
});
|
|
101
123
|
}
|
|
102
124
|
|
|
@@ -144,3 +166,10 @@ function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boo
|
|
|
144
166
|
}
|
|
145
167
|
return false;
|
|
146
168
|
}
|
|
169
|
+
|
|
170
|
+
/** Each named write this page still holds, settled against the rows the frame merged. */
|
|
171
|
+
function settle(store: RecordStore, writes: readonly string[], merged: Iterable<RecordKey>): void {
|
|
172
|
+
if (writes.length === 0) return;
|
|
173
|
+
const carried = new Set(merged);
|
|
174
|
+
for (const write of writes) store.settleWrite(write, carried);
|
|
175
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// When a live subscribe spends the read's declared `rateLimit:`, and against whom. Split from
|
|
2
|
+
// `live-query.ts` at its size ceiling: the registry decides WHAT is served, this decides what it
|
|
3
|
+
// costs — once per subscribe that reads, on the node's clock, keyed by the socket's own address.
|
|
4
|
+
|
|
5
|
+
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { LiveCursor } from './cursor';
|
|
7
|
+
import type { JsonValue } from './json';
|
|
8
|
+
import type { LiveQueryDefinition } from './live-contract';
|
|
9
|
+
import type { SyncSocket } from './socket';
|
|
10
|
+
|
|
11
|
+
export interface SubscribeArgs {
|
|
12
|
+
readonly socket: SyncSocket;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly input: JsonValue;
|
|
15
|
+
readonly sid?: string;
|
|
16
|
+
readonly cursor?: LiveCursor | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One subscribe's bill, shared by every pass of it. `subscribe` serves AGAIN when the socket's
|
|
21
|
+
* actor is replaced under it, and that second pass is the same subscription: spending there
|
|
22
|
+
* charged one subscribe twice.
|
|
23
|
+
*/
|
|
24
|
+
export interface Charge {
|
|
25
|
+
due: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Spend once, then never again for this subscribe — called by the registry immediately before
|
|
30
|
+
* any DATABASE READ this subscriber causes: a cold snapshot, a resume that fell back to one, or a
|
|
31
|
+
* delta resume onto an entry nothing has read yet. Only a real in-window delta served out of the
|
|
32
|
+
* ring is free. Never decided from the cursor: its `qid` is computable from the query, the input
|
|
33
|
+
* and the org, so a cursor is a client's claim to a window, not proof it held one — trusting it
|
|
34
|
+
* made every subscribe+unsubscribe cycle a free read. `due` drops before the await, so a pass
|
|
35
|
+
* racing this one (the re-auth retry) cannot pay as well.
|
|
36
|
+
*/
|
|
37
|
+
export async function spendOnce(
|
|
38
|
+
definition: LiveQueryDefinition,
|
|
39
|
+
socket: SyncSocket,
|
|
40
|
+
charge: Charge,
|
|
41
|
+
clock: Clock,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!charge.due) return;
|
|
44
|
+
charge.due = false;
|
|
45
|
+
await definition.spend?.({
|
|
46
|
+
actor: socket.actor,
|
|
47
|
+
clientAddress: socket.clientAddress,
|
|
48
|
+
nowMs: clock.now().getTime(),
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/local-store-idb.ts
CHANGED
|
@@ -115,6 +115,11 @@ export class MemoryLocalStore implements LocalStore {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** The one way to build the in-process store (tests, a browser with no IndexedDB); the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
|
|
119
|
+
export function memoryLocalStore(): MemoryLocalStore {
|
|
120
|
+
return new MemoryLocalStore();
|
|
121
|
+
}
|
|
122
|
+
|
|
118
123
|
class IdbLocalStore implements LocalStore {
|
|
119
124
|
readonly kind = 'indexeddb';
|
|
120
125
|
constructor(private readonly db: IdbDatabaseLike) {}
|
package/src/matcher-bridge.ts
CHANGED
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import { type LiveQuery, match, type Patch } from '@ultimat3/query';
|
|
8
8
|
import type { ChangeEvent } from './changefeed';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
changedColumns,
|
|
11
|
+
isParsedJsonObject,
|
|
12
|
+
type JsonObject,
|
|
13
|
+
type Row,
|
|
14
|
+
type RowPatch,
|
|
15
|
+
} from './json';
|
|
10
16
|
|
|
11
17
|
export interface SubscriptionShape {
|
|
12
18
|
readonly qid: string;
|
|
@@ -37,7 +43,7 @@ export interface IncrementalMatcher {
|
|
|
37
43
|
|
|
38
44
|
/** The cheap pre-filter. Pure, and the only thing that runs for a change nobody subscribed to. */
|
|
39
45
|
export function canAffect(shape: SubscriptionShape, change: ChangeEvent): boolean {
|
|
40
|
-
if (!shape.entities.includes(change.
|
|
46
|
+
if (!shape.entities.includes(change.table)) return false;
|
|
41
47
|
if (shape.orgId !== null && change.orgId !== null && shape.orgId !== change.orgId) return false;
|
|
42
48
|
if (shape.columns && change.op === 'update' && change.after !== null) {
|
|
43
49
|
const touched = Object.keys(changedColumns(change.before, change.after));
|
|
@@ -89,7 +95,7 @@ export function matcherFor(live: LiveQuery, projection?: () => Projection): Incr
|
|
|
89
95
|
const row = change.after ?? change.before;
|
|
90
96
|
if (!row) return NO_CHANGE;
|
|
91
97
|
const patches = match<Row>(live.name, live.shape, rows, {
|
|
92
|
-
entity: change.
|
|
98
|
+
entity: change.table,
|
|
93
99
|
op: change.op,
|
|
94
100
|
row,
|
|
95
101
|
...(change.before === null ? {} : { before: change.before }),
|
|
@@ -200,7 +206,7 @@ export function applyToWindow(rows: readonly Row[], patches: readonly RowPatch[]
|
|
|
200
206
|
export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatch | null {
|
|
201
207
|
if (candidate === null || candidate === undefined || candidate === false) return null;
|
|
202
208
|
if (candidate === true) return patchFromChange(change);
|
|
203
|
-
if (!
|
|
209
|
+
if (!isParsedJsonObject(candidate)) return null;
|
|
204
210
|
const op = candidate['op'];
|
|
205
211
|
if (op !== 'insert' && op !== 'update' && op !== 'delete') return patchFromChange(change);
|
|
206
212
|
const id = candidate['id'];
|
|
@@ -209,7 +215,7 @@ export function normalizePatch(candidate: unknown, change: ChangeEvent): RowPatc
|
|
|
209
215
|
const base: RowPatch = {
|
|
210
216
|
op,
|
|
211
217
|
id: typeof id === 'string' ? id : (change.after?.id ?? change.before?.id ?? ''),
|
|
212
|
-
row: op === 'delete' ? null :
|
|
218
|
+
row: op === 'delete' ? null : isParsedJsonObject(rowValue) ? rowValue : (change.after ?? null),
|
|
213
219
|
lsn: typeof candidate['lsn'] === 'string' ? candidate['lsn'] : change.lsn,
|
|
214
220
|
};
|
|
215
221
|
if (base.id === '') return null;
|
package/src/nats-client.ts
CHANGED
|
@@ -33,8 +33,8 @@ export interface NatsRequestManyOptions {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* What the bus needs from a NATS connection, and nothing more. A subscription outlives a reconnect —
|
|
36
|
-
* the client re-establishes it underneath the caller
|
|
37
|
-
*
|
|
36
|
+
* the client re-establishes it underneath the caller. It does not outlive the client: once the
|
|
37
|
+
* reconnect budget is spent (`onClosed`), the caller dials a new client and binds again.
|
|
38
38
|
*/
|
|
39
39
|
export interface NatsClient {
|
|
40
40
|
/** The connected server's version, as `assertServerVersion` reads it. */
|
|
@@ -75,6 +75,11 @@ export interface NatsClientOptions {
|
|
|
75
75
|
readonly onError?: ((error: unknown) => void) | undefined;
|
|
76
76
|
/** The library re-established the connection. The cluster behind it may be a different one. */
|
|
77
77
|
readonly onReconnect?: (() => void) | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The library gave up: its reconnect budget is spent and this connection is closed for good,
|
|
80
|
+
* subscriptions included. Never called for the caller's own `close()`. The caller re-dials.
|
|
81
|
+
*/
|
|
82
|
+
readonly onClosed?: (() => void) | undefined;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/** The one seam a test replaces: a client that never touches a socket. */
|
|
@@ -85,6 +90,23 @@ export const DEFAULT_NATS_PORT = 4222;
|
|
|
85
90
|
const NATS_URL_FIX = 'export NATS_URL=nats://user:password@host:4222';
|
|
86
91
|
export const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
87
92
|
|
|
93
|
+
/**
|
|
94
|
+
* One URL credential, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray
|
|
95
|
+
* `%` (`50%off`), so a secret pasted unencoded failed boot with no code and no fix. Refused under
|
|
96
|
+
* the part's NAME and the host — never the value: it is the credential, and a cause reaches a log.
|
|
97
|
+
*/
|
|
98
|
+
function decodeCredential(raw: string, part: 'user' | 'password', host: string): string {
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new TransportUnavailableError({
|
|
103
|
+
transport: 'nats',
|
|
104
|
+
reason: `the connection URL for ${host} has a malformed percent-escape in its ${part}`,
|
|
105
|
+
fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of NATS_URL`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
/**
|
|
89
111
|
* `nats://user:pass@host:4222`. The one place a bus URL is read — the library takes a bare
|
|
90
112
|
* `host:port` plus credentials as options, and never looks at a URL's userinfo.
|
|
@@ -127,8 +149,8 @@ export function parseNatsUrl(url: string): NatsTarget {
|
|
|
127
149
|
fix: 'set the URL to nats://<user>:<pass>@host:4222, or the bare-token form nats://<token>@host:4222',
|
|
128
150
|
});
|
|
129
151
|
}
|
|
130
|
-
const user = hasUser ?
|
|
131
|
-
const pass = hasPass ?
|
|
152
|
+
const user = hasUser ? decodeCredential(parsed.username, 'user', parsed.hostname) : undefined;
|
|
153
|
+
const pass = hasPass ? decodeCredential(parsed.password, 'password', parsed.hostname) : undefined;
|
|
132
154
|
return {
|
|
133
155
|
host: parsed.hostname,
|
|
134
156
|
port: parsed.port === '' ? DEFAULT_NATS_PORT : Number.parseInt(parsed.port, 10),
|
package/src/nats-lib-client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// WHERE FAILURES ARE TRANSLATED, and it is deliberately not all here. This file coded the calls
|
|
6
6
|
// that have no synchronous caller frame to catch them: `request`, `requestMany`, the dial, and the
|
|
7
|
-
// background `#watch
|
|
7
|
+
// background `#watch`/`#watchClosed`, the only places a lost connection is announced. `publish` and
|
|
8
8
|
// `subscribe` are synchronous and stay raw — `NatsTransport.#translating` codes them, because
|
|
9
9
|
// `NatsTransportOptions.connect` is a PUBLIC injection seam: translating in this class would cover
|
|
10
10
|
// the one client the repo ships and leave every app-supplied one uncovered, and translating in both
|
|
@@ -72,6 +72,8 @@ class LibNatsClient implements NatsClient {
|
|
|
72
72
|
readonly #timeoutMs: number;
|
|
73
73
|
readonly #report: (error: unknown) => void;
|
|
74
74
|
#connected = true;
|
|
75
|
+
/** Our own `close()` ends the status stream too, and that end is not the library giving up. */
|
|
76
|
+
#closing = false;
|
|
75
77
|
|
|
76
78
|
constructor(connection: NatsConnection, target: NatsTarget, options: NatsClientOptions) {
|
|
77
79
|
this.#connection = connection;
|
|
@@ -155,19 +157,22 @@ class LibNatsClient implements NatsClient {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
async close(): Promise<void> {
|
|
160
|
+
this.#closing = true;
|
|
158
161
|
this.#connected = false;
|
|
159
162
|
await this.#connection.close();
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
/**
|
|
163
|
-
* The library's own status stream is the only place a
|
|
166
|
+
* The library's own status stream is the only place a drop or a recovery is announced. Nothing
|
|
164
167
|
* awaits it, so it can neither throw nor end the process: a drop reports and flips `connected`,
|
|
165
168
|
* a reconnect flips it back and tells the transport its cluster may be a new one.
|
|
166
169
|
*/
|
|
167
170
|
async #watch(options: NatsClientOptions): Promise<void> {
|
|
168
171
|
const report = options.onError ?? ((): void => undefined);
|
|
172
|
+
const stream = this.#connection.status();
|
|
173
|
+
void this.#watchClosed(options, stream);
|
|
169
174
|
try {
|
|
170
|
-
for await (const status of
|
|
175
|
+
for await (const status of stream) {
|
|
171
176
|
if (status.type === Events.Disconnect) {
|
|
172
177
|
this.#connected = false;
|
|
173
178
|
report(unavailable(this.#target, 'the connection dropped'));
|
|
@@ -178,17 +183,41 @@ class LibNatsClient implements NatsClient {
|
|
|
178
183
|
report(unavailable(this.#target, `the server reported ${String(status.data)}`));
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
|
-
// The iterator ends when the connection is done: either `close()` or a reconnect budget spent.
|
|
182
|
-
this.#connected = false;
|
|
183
|
-
const failure = await this.#connection.closed();
|
|
184
|
-
if (failure !== undefined) report(unavailable(this.#target, describe(failure)));
|
|
185
186
|
} catch (error) {
|
|
186
187
|
this.#connected = false;
|
|
187
188
|
report(unavailable(this.#target, describe(error)));
|
|
188
189
|
}
|
|
189
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The end of the connection, read off `closed()` — never off the status stream, which in
|
|
194
|
+
* nats@2.29.3 does NOT end when the connection closes (the library stops its protocol's
|
|
195
|
+
* listeners, not the connection's). A close we did not ask for is the library giving up on its
|
|
196
|
+
* reconnect budget: `onClosed`, so the transport replaces this client rather than handing a dead
|
|
197
|
+
* one out forever. The stream is stopped here, or every replaced client leaks a pending loop.
|
|
198
|
+
*/
|
|
199
|
+
async #watchClosed(options: NatsClientOptions, stream: unknown): Promise<void> {
|
|
200
|
+
let failure: unknown;
|
|
201
|
+
try {
|
|
202
|
+
failure = await this.#connection.closed();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
failure = error;
|
|
205
|
+
}
|
|
206
|
+
this.#connected = false;
|
|
207
|
+
stopStream(stream);
|
|
208
|
+
if (this.#closing) return;
|
|
209
|
+
if (failure !== undefined) options.onError?.(unavailable(this.#target, describe(failure)));
|
|
210
|
+
options.onClosed?.();
|
|
211
|
+
}
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
/** `QueuedIterator.stop()` is the library's, and missing from the `AsyncIterable` it is typed as. */
|
|
215
|
+
const stopStream = (stream: unknown): void => {
|
|
216
|
+
if (typeof stream !== 'object' || stream === null || !('stop' in stream)) return;
|
|
217
|
+
const stop: unknown = stream.stop;
|
|
218
|
+
if (typeof stop === 'function') stop.call(stream);
|
|
219
|
+
};
|
|
220
|
+
|
|
192
221
|
/**
|
|
193
222
|
* The production `NatsConnect`. The first dial retries on the same budget as a later loss
|
|
194
223
|
* (`waitOnFirstConnect`), so a `sync` container that raced the bus into readiness recovers on its
|