@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,42 @@
|
|
|
1
|
+
// The ONE `new WebSocket` in the framework. The page opens one socket, through this adapter; an
|
|
2
|
+
// island never dials, and neither does an app. Four handlers and a send — the reconnect, the
|
|
3
|
+
// backoff and the heartbeat all stay in `LiveClient`, which is why this is the whole adapter.
|
|
4
|
+
|
|
5
|
+
import type { ClientSocket } from './client-contract';
|
|
6
|
+
import type { SyncTarget } from './page-store';
|
|
7
|
+
|
|
8
|
+
export function browserSocket(url: string): ClientSocket {
|
|
9
|
+
const socket = new WebSocket(url);
|
|
10
|
+
return {
|
|
11
|
+
send: (data: string): void => {
|
|
12
|
+
socket.send(data);
|
|
13
|
+
},
|
|
14
|
+
close: (code?: number, reason?: string): void => {
|
|
15
|
+
socket.close(code, reason);
|
|
16
|
+
},
|
|
17
|
+
onOpen: (handler: () => void): void => {
|
|
18
|
+
socket.onopen = (): void => {
|
|
19
|
+
handler();
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
onMessage: (handler: (data: string) => void): void => {
|
|
23
|
+
socket.onmessage = (event: MessageEvent): void => {
|
|
24
|
+
handler(String(event.data));
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
onClose: (handler: (code: number) => void): void => {
|
|
28
|
+
socket.onclose = (event: CloseEvent): void => {
|
|
29
|
+
handler(event.code);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
get bufferedAmount(): number {
|
|
33
|
+
return socket.bufferedAmount;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `?build=` rides the dial, so a stale tab is told to reload on the socket it opens. */
|
|
39
|
+
export function dialUrl(target: SyncTarget): string {
|
|
40
|
+
const joiner = target.url.includes('?') ? '&' : '?';
|
|
41
|
+
return `${target.url}${joiner}build=${encodeURIComponent(target.buildId)}`;
|
|
42
|
+
}
|
package/src/changefeed.ts
CHANGED
|
@@ -11,7 +11,13 @@ import type { PgTarget } from './pg-socket';
|
|
|
11
11
|
import type { PgStream } from './pg-wire';
|
|
12
12
|
import type { Rng } from './thundering-herd';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `truncate` carries no row — `before` and `after` are both `null` — and means every row of the
|
|
16
|
+
* relation is gone. A window cannot be patched from it, only re-read; a channel's members are told
|
|
17
|
+
* to re-read (`replay-gap`). It was decoded and dropped, so with the recommended `FOR ALL TABLES`
|
|
18
|
+
* publication every window and every client kept the truncated rows forever.
|
|
19
|
+
*/
|
|
20
|
+
export type ChangeOp = 'insert' | 'update' | 'delete' | 'truncate';
|
|
15
21
|
|
|
16
22
|
export interface ChangeEvent<R extends Row = Row> {
|
|
17
23
|
/** Entity name, not table name — the matcher's dependency sets are declared in entity terms. */
|
|
@@ -26,6 +32,13 @@ export interface ChangeEvent<R extends Row = Row> {
|
|
|
26
32
|
readonly orgId: string | null;
|
|
27
33
|
/** Commit time, epoch ms. */
|
|
28
34
|
readonly at: number;
|
|
35
|
+
/**
|
|
36
|
+
* The write that made this change: `writeDigest` of the idempotency key its request carried
|
|
37
|
+
* (`@ultimat3/core`). Absent for a change no keyed request made. Read off the WAL message the
|
|
38
|
+
* Postgres driver writes first in the transaction, or off the request scope in-process; a
|
|
39
|
+
* channel stamps it on the `records` frame so the writing page recognises its own echo.
|
|
40
|
+
*/
|
|
41
|
+
readonly write?: string;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export interface ChangeFeedStartOptions {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A declared channel's policy, asked on subscribe: the params are the input and the row is what
|
|
2
|
+
// the channel's own loader answered. Through `@ultimat3/query`'s `guard`, the package's one authz
|
|
3
|
+
// seam — the same call `policy-gate.ts` makes for a live query.
|
|
4
|
+
|
|
5
|
+
import { type Actor, type Ctx, createContext, runWithContext } from '@ultimat3/core';
|
|
6
|
+
import { guard, QueryDeniedError } from '@ultimat3/query';
|
|
7
|
+
import type { Channel } from './channel-decl';
|
|
8
|
+
import { TopicForbiddenError } from './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A denial is `X_TOPIC_FORBIDDEN`. A loader or a rule that RAISED is not a denial and leaves as it
|
|
12
|
+
* came, so the caller can tell an outage from a decision — `onActorChange` keeps the topic on one.
|
|
13
|
+
*/
|
|
14
|
+
export async function authorizeChannel(
|
|
15
|
+
channel: Channel,
|
|
16
|
+
ctx: Ctx,
|
|
17
|
+
actor: Actor | null,
|
|
18
|
+
topic: string,
|
|
19
|
+
params: Readonly<Record<string, string>>,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
// The loader and the rule run AS the subscriber, never as the node. Under the node's context — no
|
|
22
|
+
// actor, no tenant — a repository read inside the loader was scoped by nothing but what the
|
|
23
|
+
// loader happened to name, and `@ultimat3/entity`'s tenancy seam had no actor to hold it to.
|
|
24
|
+
// Services are rebuilt for this actor by `createContext`, the rule `withChildContext` follows.
|
|
25
|
+
const scoped = actor === null ? ctx : subscriberContext(ctx, actor);
|
|
26
|
+
const row =
|
|
27
|
+
channel.row === undefined
|
|
28
|
+
? null
|
|
29
|
+
: await runWithContext(scoped, async () => await channel.row?.({ params, ctx: scoped }));
|
|
30
|
+
try {
|
|
31
|
+
guard(channel.policy, { actor, input: params, row, ctx: scoped, query: channel.name }, 'live');
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!(error instanceof QueryDeniedError)) throw error;
|
|
34
|
+
throw new TopicForbiddenError({
|
|
35
|
+
topic,
|
|
36
|
+
actorId: actor === null ? null : actor.id,
|
|
37
|
+
reason: `channel "${channel.name}" policy denied the subscribe`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The node's context, re-made for one subscriber: same deploy, role and clock, their actor. */
|
|
43
|
+
function subscriberContext(node: Ctx, actor: Actor): Ctx {
|
|
44
|
+
return createContext({
|
|
45
|
+
actor,
|
|
46
|
+
role: node.role,
|
|
47
|
+
buildId: node.buildId,
|
|
48
|
+
clock: node.clock,
|
|
49
|
+
locale: node.locale,
|
|
50
|
+
tz: node.tz,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One topic's transport bridge into this node — the shape `ChannelHub` refcounts, and its safe
|
|
2
|
+
// close.
|
|
3
|
+
|
|
4
|
+
import type { TransportSubscription } from './fanout';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
|
|
8
|
+
* into the table before it is awaited: looked up before the await and written after it, two sockets
|
|
9
|
+
* reaching one topic at once opened two transport subscriptions — the second replacing the first in
|
|
10
|
+
* the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
|
|
11
|
+
* anything else, delivering every message on that topic a second time for the life of the process.
|
|
12
|
+
*
|
|
13
|
+
* `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
|
|
14
|
+
* runs, so the reservation has to exist before there is anything to reserve it with.
|
|
15
|
+
*/
|
|
16
|
+
export interface Bridge {
|
|
17
|
+
sub: Promise<TransportSubscription> | null;
|
|
18
|
+
refs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A bridge released while its subscription is still opening still has to be closed — the transport
|
|
23
|
+
* hands the handle back after the caller has gone, and dropping the promise would leave a live
|
|
24
|
+
* subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
|
|
25
|
+
* rejection was already answered to the subscriber that caused it.
|
|
26
|
+
*/
|
|
27
|
+
export function unsubscribeWhenOpen(bridge: Bridge): void {
|
|
28
|
+
void bridge.sub?.then(
|
|
29
|
+
(sub) => {
|
|
30
|
+
sub.unsubscribe();
|
|
31
|
+
},
|
|
32
|
+
() => undefined,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `channel(name, { params, policy, catchUp, records, events })` — the ONLY way to declare a channel,
|
|
2
|
+
// and through its ref the only way to spell a topic. `channel(ref, { records, policy })` is the
|
|
3
|
+
// same declaration for a ref an island already holds (`channel-ref.ts`): the browser chunk then
|
|
4
|
+
// carries the name and params, never `@ultimat3/entity` (plan 101, slice 09).
|
|
5
|
+
|
|
6
|
+
import type { Ctx, Row } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
type ProjectedEntity,
|
|
9
|
+
type RecordProjection,
|
|
10
|
+
recordProjection,
|
|
11
|
+
} from '@ultimat3/entity/record';
|
|
12
|
+
import type { QueryPolicy } from '@ultimat3/query';
|
|
13
|
+
import {
|
|
14
|
+
type ChannelHandle,
|
|
15
|
+
type ChannelParams,
|
|
16
|
+
type ChannelRefInit,
|
|
17
|
+
channelRef,
|
|
18
|
+
refuseChannel,
|
|
19
|
+
SEGMENT,
|
|
20
|
+
type Topic,
|
|
21
|
+
} from './channel-ref';
|
|
22
|
+
import { registerChannel } from './channel-registry';
|
|
23
|
+
|
|
24
|
+
export { type ChannelParams, type Topic, topic } from './channel-ref';
|
|
25
|
+
|
|
26
|
+
/** What `records: [posts]` accepts: anything `entity()` built. */
|
|
27
|
+
export type ChannelEntity = ProjectedEntity;
|
|
28
|
+
|
|
29
|
+
/** `recordProjection`, refused in this declaration's words rather than the entity's. */
|
|
30
|
+
function projectionFor(name: string, entity: ChannelEntity): RecordProjection {
|
|
31
|
+
try {
|
|
32
|
+
return recordProjection(entity);
|
|
33
|
+
} catch {
|
|
34
|
+
return refuseChannel(
|
|
35
|
+
name,
|
|
36
|
+
`records lists "${entity.$name}", which carries no record brand`,
|
|
37
|
+
'list entities declared with entity() from @ultimat3/entity',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What only the server half declares: the rows it carries and who may join. */
|
|
43
|
+
export interface ChannelServerInit {
|
|
44
|
+
/**
|
|
45
|
+
* Evaluated on subscribe: the params are the input, and `row` is what the loader below answered
|
|
46
|
+
* (`null` without one). REQUIRED, as on an `action` and a `query`: it was optional, and an omitted
|
|
47
|
+
* one let any socket — anonymous included — join with any param and receive every committed row.
|
|
48
|
+
* A public channel says so with `allow('public')`. Records are NOT gated per row — the topic's
|
|
49
|
+
* params are the scope, so a channel that must hide rows is declared narrower.
|
|
50
|
+
*/
|
|
51
|
+
readonly policy: QueryPolicy;
|
|
52
|
+
/**
|
|
53
|
+
* The subject the policy decides about, loaded by the SURFACE before the rule runs — the same
|
|
54
|
+
* split an action's `row:` makes, because a rule is synchronous and membership is a read.
|
|
55
|
+
*/
|
|
56
|
+
readonly row?: ChannelRowLoader;
|
|
57
|
+
/** Entities whose committed rows this channel carries as records, matched to params BY NAME. */
|
|
58
|
+
readonly records?: readonly ChannelEntity[];
|
|
59
|
+
/** Whether the channel also carries ephemeral `events` frames. */
|
|
60
|
+
readonly events?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ChannelInit<K extends string> = ChannelRefInit<K> & ChannelServerInit;
|
|
64
|
+
|
|
65
|
+
export type ChannelRowLoader = (args: {
|
|
66
|
+
readonly params: Readonly<Record<string, string>>;
|
|
67
|
+
readonly ctx: Ctx;
|
|
68
|
+
}) => unknown;
|
|
69
|
+
|
|
70
|
+
export interface Channel<K extends string = string> {
|
|
71
|
+
readonly kind: 'channel';
|
|
72
|
+
readonly name: string;
|
|
73
|
+
readonly params: readonly K[];
|
|
74
|
+
readonly policy: QueryPolicy;
|
|
75
|
+
readonly row: ChannelRowLoader | undefined;
|
|
76
|
+
readonly catchUp: string;
|
|
77
|
+
readonly records: readonly RecordProjection[];
|
|
78
|
+
readonly events: boolean;
|
|
79
|
+
/** The topic for one set of params — the one spelling both sides use. */
|
|
80
|
+
topic(params: ChannelParams<K>): Topic;
|
|
81
|
+
/**
|
|
82
|
+
* The params a committed row of a listed entity belongs to: each param read off the row's
|
|
83
|
+
* property of the SAME NAME. `null` when the row lacks one (a partial `before` image).
|
|
84
|
+
*/
|
|
85
|
+
paramsOf(row: Row): ChannelParams<K> | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Registers the declaration (`channel-registry.ts`) — a second one of the same name is refused.
|
|
90
|
+
*
|
|
91
|
+
* The params-matching rule, decided here once: a row belongs to the topic whose params equal the
|
|
92
|
+
* row's own properties of those names. So `channel('org-feed', { params: ['orgId'], policy, records:
|
|
93
|
+
* [posts] })` carries every `posts` row on `org-feed.<row.orgId>`, and every listed entity must have
|
|
94
|
+
* a column named after every param — refused at declaration, where the author is.
|
|
95
|
+
*/
|
|
96
|
+
export function channel<const K extends string>(name: string, init: ChannelInit<K>): Channel<K>;
|
|
97
|
+
export function channel<K extends string>(
|
|
98
|
+
ref: ChannelHandle<K>,
|
|
99
|
+
init: ChannelServerInit,
|
|
100
|
+
): Channel<K>;
|
|
101
|
+
export function channel<K extends string>(
|
|
102
|
+
nameOrRef: string | ChannelHandle<K>,
|
|
103
|
+
init: ChannelInit<K> | ChannelServerInit,
|
|
104
|
+
): Channel<K> {
|
|
105
|
+
const ref =
|
|
106
|
+
typeof nameOrRef === 'string' ? channelRef(nameOrRef, init as ChannelInit<K>) : nameOrRef;
|
|
107
|
+
const name = ref.name;
|
|
108
|
+
// The type requires it; this is the refusal a JS caller, or a cast, reaches.
|
|
109
|
+
const policy = (init as Partial<ChannelServerInit>).policy;
|
|
110
|
+
if (policy === undefined) {
|
|
111
|
+
refuseChannel(
|
|
112
|
+
name,
|
|
113
|
+
'declares no policy, so any socket, anonymous included, could join and receive every row',
|
|
114
|
+
"add policy: can('<resource>:read') to the channel() call, or policy: allow('public') for a channel anyone may join",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const records = (init.records ?? []).map((entity) => {
|
|
118
|
+
const projection = projectionFor(name, entity);
|
|
119
|
+
for (const param of ref.params) {
|
|
120
|
+
if (!Object.hasOwn(projection.schema.properties ?? {}, param)) {
|
|
121
|
+
refuseChannel(
|
|
122
|
+
name,
|
|
123
|
+
`param "${param}" is not a column of "${projection.type}", so its rows match no topic`,
|
|
124
|
+
`rename the param to a column every listed entity has, or drop ${projection.type} from records`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return projection;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const declared: Channel<K> = Object.freeze({
|
|
132
|
+
kind: 'channel' as const,
|
|
133
|
+
name,
|
|
134
|
+
params: ref.params,
|
|
135
|
+
policy,
|
|
136
|
+
row: init.row,
|
|
137
|
+
get catchUp(): string {
|
|
138
|
+
return ref.catchUp;
|
|
139
|
+
},
|
|
140
|
+
records,
|
|
141
|
+
events: init.events === true,
|
|
142
|
+
topic: ref.topic,
|
|
143
|
+
paramsOf: (row: Row): ChannelParams<K> | null => {
|
|
144
|
+
const params: Partial<Record<K, string>> = {};
|
|
145
|
+
for (const param of ref.params) {
|
|
146
|
+
const value = Object.hasOwn(row, param) ? row[param] : undefined;
|
|
147
|
+
if (typeof value !== 'string' || !SEGMENT.test(value)) return null;
|
|
148
|
+
params[param] = value;
|
|
149
|
+
}
|
|
150
|
+
return params as ChannelParams<K>;
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
registerChannel(declared);
|
|
154
|
+
return declared;
|
|
155
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The channel registry as the manifest reads it: one plain fact per declared channel, the policy
|
|
2
|
+
// flattened the way a query's is. Server-side on purpose — the policy flattening is
|
|
3
|
+
// `@ultimat3/query`'s, and a browser island declaring a channel must not bundle it.
|
|
4
|
+
|
|
5
|
+
import { policyCapability, policyPermissions } from '@ultimat3/query';
|
|
6
|
+
import { registeredChannels } from './channel-registry';
|
|
7
|
+
|
|
8
|
+
export interface ChannelDescription {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly params: readonly string[];
|
|
11
|
+
/** The catch-up query's name, as `registerQueries` stamped it. */
|
|
12
|
+
readonly catchUp: string;
|
|
13
|
+
/** Record types (entity names) the channel carries, sorted. */
|
|
14
|
+
readonly records: readonly string[];
|
|
15
|
+
readonly events: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The policy's display label. Never absent since 22.0.0 — `channel()` requires a policy, and a
|
|
18
|
+
* channel any socket may join reads `allow('public')`'s label, said out loud.
|
|
19
|
+
*/
|
|
20
|
+
readonly policy: string;
|
|
21
|
+
/** Every permission the policy asserts, flattened — what a report matches a grant against. */
|
|
22
|
+
readonly permissions: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function describeChannels(): readonly ChannelDescription[] {
|
|
26
|
+
return registeredChannels().map((declared) => ({
|
|
27
|
+
name: declared.name,
|
|
28
|
+
params: [...declared.params],
|
|
29
|
+
catchUp: declared.catchUp,
|
|
30
|
+
records: declared.records.map((projection) => projection.type).sort(),
|
|
31
|
+
events: declared.events,
|
|
32
|
+
policy: policyCapability(declared.policy),
|
|
33
|
+
permissions: [...policyPermissions(declared.policy)].sort(),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The repair half of a dropped `records` frame: the per-socket mark is `SyncSocket.gaps`, and this
|
|
2
|
+
// is what answers it — one `replay-gap` per (socket, topic), sent once the socket takes frames
|
|
3
|
+
// again, counted in `channel_replay_gaps_total` beside `channel_frames_dropped_total`.
|
|
4
|
+
|
|
5
|
+
import { type Counter, counter } from '@ultimat3/core';
|
|
6
|
+
import type { ReplayGapFrame } from './channel-wire';
|
|
7
|
+
import type { SyncSocket } from './socket';
|
|
8
|
+
import { PROTOCOL_VERSION } from './sync-protocol';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `replay-gap` frames this node ANNOUNCED — a socket took the frame. Not "repaired": the re-read
|
|
12
|
+
* is the client's, and a node can only count what it said, never what a browser did about it.
|
|
13
|
+
*/
|
|
14
|
+
const channelReplayGaps: Counter = counter('channel_replay_gaps_total', {
|
|
15
|
+
unit: '{gap}',
|
|
16
|
+
description: 'replay-gap frames delivered to a socket that lost a channel records frame',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export class GapRepairs {
|
|
20
|
+
#announced = 0;
|
|
21
|
+
|
|
22
|
+
/** `replay-gap` frames delivered since boot — the in-process read of the series above. */
|
|
23
|
+
get announced(): number {
|
|
24
|
+
return this.#announced;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sends the one `replay-gap` this socket owes for `topic`, if any. `true` when one went out. */
|
|
28
|
+
repair(socket: SyncSocket, topic: string): boolean {
|
|
29
|
+
const epoch = socket.gaps.get(topic);
|
|
30
|
+
if (epoch === undefined) return false;
|
|
31
|
+
const frame: ReplayGapFrame = {
|
|
32
|
+
type: 'replay-gap',
|
|
33
|
+
v: PROTOCOL_VERSION,
|
|
34
|
+
channel: topic,
|
|
35
|
+
epoch,
|
|
36
|
+
};
|
|
37
|
+
if (!socket.send(frame)) return false;
|
|
38
|
+
socket.gaps.delete(topic);
|
|
39
|
+
this.#announced += 1;
|
|
40
|
+
channelReplayGaps.add(1);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Everything this socket is owed — for Bun's `drain` callback. Answers how many went out. */
|
|
45
|
+
repairAll(socket: SyncSocket): number {
|
|
46
|
+
let repaired = 0;
|
|
47
|
+
for (const topic of [...socket.gaps.keys()]) if (this.repair(socket, topic)) repaired += 1;
|
|
48
|
+
return repaired;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Marks not yet answered, across `sockets`. Zero is a node owing no repair. */
|
|
52
|
+
pending(sockets: Iterable<SyncSocket>): number {
|
|
53
|
+
let pending = 0;
|
|
54
|
+
for (const socket of sockets) pending += socket.gaps.size;
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Every declared channel topic this node delivers records on: its ring (epoch, seq, recent frames),
|
|
2
|
+
// the change → frame path, and the resume a resubscribe `since` asks for. One per `ChannelHub`.
|
|
3
|
+
// Records never cross the bus — each node reads every change itself — so seq is minted here.
|
|
4
|
+
|
|
5
|
+
import { logger, renderThrowable, uuid } from '@ultimat3/core';
|
|
6
|
+
import type { ChangeEvent } from './changefeed';
|
|
7
|
+
import type { Channel } from './channel-decl';
|
|
8
|
+
import { carriesTable, updatesFor } from './channel-records';
|
|
9
|
+
import { renderRecords } from './channel-render';
|
|
10
|
+
import { ChannelRing } from './channel-ring';
|
|
11
|
+
import type { ChannelSince, ReplayGapFrame } from './channel-wire';
|
|
12
|
+
import type { SocketRegistry, SyncSocket } from './socket';
|
|
13
|
+
import { PROTOCOL_VERSION } from './sync-protocol';
|
|
14
|
+
|
|
15
|
+
/** What a joined topic resolves to. A topic is `name.params…`, so it names both exactly. */
|
|
16
|
+
export interface ChannelTopic {
|
|
17
|
+
readonly channel: Channel;
|
|
18
|
+
readonly params: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ChannelLogs {
|
|
22
|
+
readonly #sockets: SocketRegistry;
|
|
23
|
+
readonly #ringSize: number | undefined;
|
|
24
|
+
/** This hub's mark on every epoch it mints: a restarted node can never reuse one. */
|
|
25
|
+
readonly #hubId = uuid();
|
|
26
|
+
#rings = 0;
|
|
27
|
+
readonly #byTopic = new Map<
|
|
28
|
+
string,
|
|
29
|
+
{ readonly ring: ChannelRing; readonly target: ChannelTopic }
|
|
30
|
+
>();
|
|
31
|
+
|
|
32
|
+
constructor(sockets: SocketRegistry, ringSize?: number) {
|
|
33
|
+
this.#sockets = sockets;
|
|
34
|
+
this.#ringSize = ringSize;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The topic's log, opened on its first local member. */
|
|
38
|
+
open(topic: string, target: ChannelTopic): ChannelRing {
|
|
39
|
+
const existing = this.#byTopic.get(topic);
|
|
40
|
+
if (existing !== undefined) return existing.ring;
|
|
41
|
+
this.#rings += 1;
|
|
42
|
+
const ring = new ChannelRing(`${this.#hubId}:${this.#rings}`, this.#ringSize);
|
|
43
|
+
this.#byTopic.set(topic, { ring, target });
|
|
44
|
+
return ring;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Dropped with the topic's last local member; the next member gets a new epoch. */
|
|
48
|
+
close(topic: string): void {
|
|
49
|
+
this.#byTopic.delete(topic);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
target(topic: string): ChannelTopic | undefined {
|
|
53
|
+
return this.#byTopic.get(topic)?.target;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One committed change → a `records` frame on every open topic it touches, rendered per socket.
|
|
58
|
+
* A key that cannot be computed (`X_RECORD_KEY_MISSING`) is logged and skipped for that channel:
|
|
59
|
+
* the change is somebody else's too, and one malformed image must not stop the rest.
|
|
60
|
+
*/
|
|
61
|
+
deliverChange(channels: Iterable<Channel>, change: ChangeEvent): number {
|
|
62
|
+
if (change.op === 'truncate') return this.#truncated(change.entity);
|
|
63
|
+
let frames = 0;
|
|
64
|
+
let updates: ReturnType<typeof updatesFor>;
|
|
65
|
+
try {
|
|
66
|
+
updates = updatesFor(channels, change);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.warn('channel.records_unkeyed', {
|
|
69
|
+
table: change.entity,
|
|
70
|
+
error: renderThrowable(error),
|
|
71
|
+
});
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
for (const update of updates) {
|
|
75
|
+
const open = this.#byTopic.get(update.topic);
|
|
76
|
+
if (open === undefined) continue;
|
|
77
|
+
const entry = open.ring.append(update.adopt, update.remove, change.write);
|
|
78
|
+
const frame = renderRecords(update.topic, open.ring.epoch, entry);
|
|
79
|
+
frames += this.#sockets.deliverRecords(update.topic, open.ring.epoch, frame);
|
|
80
|
+
}
|
|
81
|
+
return frames;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Every row of `table` is gone, and no frame can say which ones a member holds. Each open topic
|
|
86
|
+
* of a channel carrying it starts a NEW epoch — nothing in the old ring may be replayed onto a
|
|
87
|
+
* table that no longer has those rows — and every member is told `replay-gap` at it, which the
|
|
88
|
+
* client answers by re-running the channel's catch-up read.
|
|
89
|
+
*/
|
|
90
|
+
#truncated(table: string): number {
|
|
91
|
+
let announced = 0;
|
|
92
|
+
for (const [topic, open] of [...this.#byTopic]) {
|
|
93
|
+
if (!carriesTable(open.target.channel, table)) continue;
|
|
94
|
+
this.#byTopic.delete(topic);
|
|
95
|
+
const ring = this.open(topic, open.target);
|
|
96
|
+
announced += this.#sockets.announceGap(topic, ring.epoch);
|
|
97
|
+
}
|
|
98
|
+
return announced;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A (re)subscribe `since` a position: every frame after it from the ring, or — when the ring
|
|
103
|
+
* cannot prove it holds them all, or the epoch moved — one `replay-gap`.
|
|
104
|
+
*
|
|
105
|
+
* A FRESH seat with no `since` is a `replay-gap` too. The client's first read of these rows went
|
|
106
|
+
* over HTTP, on another connection, before this seat existed, so a commit between the two
|
|
107
|
+
* reached nobody — and nothing the client holds can name it. The seat is the one point after
|
|
108
|
+
* which every commit is a frame, so the re-read is told to start from here. A repeated `add` on
|
|
109
|
+
* a socket already seated (the presence beat), or a channel with no records, is sent nothing.
|
|
110
|
+
*/
|
|
111
|
+
resume(socket: SyncSocket, topic: string, since: ChannelSince | undefined, fresh: boolean): void {
|
|
112
|
+
const open = this.#byTopic.get(topic);
|
|
113
|
+
if (open === undefined) return;
|
|
114
|
+
// An events-only channel (typing, a cursor) carries no rows, so there is nothing to re-read.
|
|
115
|
+
if (since === undefined && (!fresh || open.target.channel.records.length === 0)) return;
|
|
116
|
+
const entries = since === undefined ? null : open.ring.since(since);
|
|
117
|
+
if (entries === null) {
|
|
118
|
+
const frame: ReplayGapFrame = {
|
|
119
|
+
type: 'replay-gap',
|
|
120
|
+
v: PROTOCOL_VERSION,
|
|
121
|
+
channel: topic,
|
|
122
|
+
epoch: open.ring.epoch,
|
|
123
|
+
};
|
|
124
|
+
if (!socket.send(frame)) socket.gaps.set(topic, open.ring.epoch);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!socket.send(renderRecords(topic, open.ring.epoch, entry))) {
|
|
129
|
+
socket.gaps.set(topic, open.ring.epoch);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Presence as a channel EVENT, not a frame kind of its own: joining a channel declared with
|
|
2
|
+
// `events: true` joins its presence set, and a roster change is one `events` frame on that topic.
|
|
3
|
+
// Browser-safe — the client reads the same shape back out with `readPresence`.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject, type JsonObject } from './json';
|
|
6
|
+
import { FRAME_LIMITS, type PresenceMember } from './sync-protocol';
|
|
7
|
+
|
|
8
|
+
export type PresenceOp = 'join' | 'leave' | 'update' | 'sync';
|
|
9
|
+
|
|
10
|
+
const OPS: readonly PresenceOp[] = ['join', 'leave', 'update', 'sync'];
|
|
11
|
+
|
|
12
|
+
/** The event payload: `{ presence, members, total? }`. `total` belongs to a full `sync` set only. */
|
|
13
|
+
export interface PresenceEvent {
|
|
14
|
+
readonly presence: PresenceOp;
|
|
15
|
+
readonly members: readonly PresenceMember[];
|
|
16
|
+
/** Members in the whole set behind a capped `sync`; never on a delta. */
|
|
17
|
+
readonly total?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function presenceEvent(
|
|
21
|
+
op: PresenceOp,
|
|
22
|
+
members: readonly PresenceMember[],
|
|
23
|
+
total?: number,
|
|
24
|
+
): JsonObject {
|
|
25
|
+
const base: JsonObject = {
|
|
26
|
+
presence: op,
|
|
27
|
+
members: members.map((m) => ({
|
|
28
|
+
id: m.id,
|
|
29
|
+
actorId: m.actorId,
|
|
30
|
+
meta: m.meta,
|
|
31
|
+
updatedAt: m.updatedAt,
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
return total === undefined ? base : { ...base, total };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The presence payload of an `events` frame, or `null` when the event is not one — an app's own
|
|
39
|
+
* events share the channel. Refuses rather than repairs: a malformed roster is `null`, never a
|
|
40
|
+
* partial one, and a list past `FRAME_LIMITS.members` is refused like any oversized frame field.
|
|
41
|
+
*/
|
|
42
|
+
export function readPresence(event: JsonObject): PresenceEvent | null {
|
|
43
|
+
const op = event['presence'];
|
|
44
|
+
const members = event['members'];
|
|
45
|
+
if (typeof op !== 'string' || !OPS.includes(op as PresenceOp)) return null;
|
|
46
|
+
if (!Array.isArray(members) || members.length > FRAME_LIMITS.members) return null;
|
|
47
|
+
const parsed: PresenceMember[] = [];
|
|
48
|
+
for (const value of members) {
|
|
49
|
+
const one = memberOf(value);
|
|
50
|
+
if (one === null) return null;
|
|
51
|
+
parsed.push(one);
|
|
52
|
+
}
|
|
53
|
+
const total = event['total'];
|
|
54
|
+
const base = { presence: op as PresenceOp, members: parsed };
|
|
55
|
+
if (total === undefined) return base;
|
|
56
|
+
// A count a UI would render: anything but a whole non-negative number is a malformed roster.
|
|
57
|
+
return typeof total === 'number' && Number.isInteger(total) && total >= 0
|
|
58
|
+
? { ...base, total }
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function memberOf(value: unknown): PresenceMember | null {
|
|
63
|
+
if (!isJsonObject(value)) return null;
|
|
64
|
+
const { id, actorId, meta, updatedAt } = value;
|
|
65
|
+
if (typeof id !== 'string' || typeof updatedAt !== 'number') return null;
|
|
66
|
+
if (actorId !== null && typeof actorId !== 'string') return null;
|
|
67
|
+
return { id, actorId, meta: isJsonObject(meta) ? meta : {}, updatedAt };
|
|
68
|
+
}
|