@ultimat3/realtime 20.2.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
|
@@ -0,0 +1,144 @@
|
|
|
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). Omitted = any socket may join. Records are NOT gated per row — the topic's
|
|
47
|
+
* params are the scope, so a channel that must hide rows is declared narrower.
|
|
48
|
+
*/
|
|
49
|
+
readonly policy?: QueryPolicy;
|
|
50
|
+
/**
|
|
51
|
+
* The subject the policy decides about, loaded by the SURFACE before the rule runs — the same
|
|
52
|
+
* split an action's `row:` makes, because a rule is synchronous and membership is a read.
|
|
53
|
+
*/
|
|
54
|
+
readonly row?: ChannelRowLoader;
|
|
55
|
+
/** Entities whose committed rows this channel carries as records, matched to params BY NAME. */
|
|
56
|
+
readonly records?: readonly ChannelEntity[];
|
|
57
|
+
/** Whether the channel also carries ephemeral `events` frames. */
|
|
58
|
+
readonly events?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ChannelInit<K extends string> = ChannelRefInit<K> & ChannelServerInit;
|
|
62
|
+
|
|
63
|
+
export type ChannelRowLoader = (args: {
|
|
64
|
+
readonly params: Readonly<Record<string, string>>;
|
|
65
|
+
readonly ctx: Ctx;
|
|
66
|
+
}) => unknown;
|
|
67
|
+
|
|
68
|
+
export interface Channel<K extends string = string> {
|
|
69
|
+
readonly kind: 'channel';
|
|
70
|
+
readonly name: string;
|
|
71
|
+
readonly params: readonly K[];
|
|
72
|
+
readonly policy: QueryPolicy | undefined;
|
|
73
|
+
readonly row: ChannelRowLoader | undefined;
|
|
74
|
+
readonly catchUp: string;
|
|
75
|
+
readonly records: readonly RecordProjection[];
|
|
76
|
+
readonly events: boolean;
|
|
77
|
+
/** The topic for one set of params — the one spelling both sides use. */
|
|
78
|
+
topic(params: ChannelParams<K>): Topic;
|
|
79
|
+
/**
|
|
80
|
+
* The params a committed row of a listed entity belongs to: each param read off the row's
|
|
81
|
+
* property of the SAME NAME. `null` when the row lacks one (a partial `before` image).
|
|
82
|
+
*/
|
|
83
|
+
paramsOf(row: Row): ChannelParams<K> | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Registers the declaration (`channel-registry.ts`) — a second one of the same name is refused.
|
|
88
|
+
*
|
|
89
|
+
* The params-matching rule, decided here once: a row belongs to the topic whose params equal the
|
|
90
|
+
* row's own properties of those names. So `channel('org-feed', { params: ['orgId'], records:
|
|
91
|
+
* [posts] })` carries every `posts` row on `org-feed.<row.orgId>`, and every listed entity must have
|
|
92
|
+
* a column named after every param — refused at declaration, where the author is.
|
|
93
|
+
*/
|
|
94
|
+
export function channel<const K extends string>(name: string, init: ChannelInit<K>): Channel<K>;
|
|
95
|
+
export function channel<K extends string>(
|
|
96
|
+
ref: ChannelHandle<K>,
|
|
97
|
+
init: ChannelServerInit,
|
|
98
|
+
): Channel<K>;
|
|
99
|
+
export function channel<K extends string>(
|
|
100
|
+
nameOrRef: string | ChannelHandle<K>,
|
|
101
|
+
init: ChannelInit<K> | ChannelServerInit,
|
|
102
|
+
): Channel<K> {
|
|
103
|
+
const ref =
|
|
104
|
+
typeof nameOrRef === 'string' ? channelRef(nameOrRef, init as ChannelInit<K>) : nameOrRef;
|
|
105
|
+
const name = ref.name;
|
|
106
|
+
const records = (init.records ?? []).map((entity) => {
|
|
107
|
+
const projection = projectionFor(name, entity);
|
|
108
|
+
for (const param of ref.params) {
|
|
109
|
+
if (!Object.hasOwn(projection.schema.properties ?? {}, param)) {
|
|
110
|
+
refuseChannel(
|
|
111
|
+
name,
|
|
112
|
+
`param "${param}" is not a column of "${projection.type}", so its rows match no topic`,
|
|
113
|
+
`rename the param to a column every listed entity has, or drop ${projection.type} from records`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return projection;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const declared: Channel<K> = Object.freeze({
|
|
121
|
+
kind: 'channel' as const,
|
|
122
|
+
name,
|
|
123
|
+
params: ref.params,
|
|
124
|
+
policy: init.policy,
|
|
125
|
+
row: init.row,
|
|
126
|
+
get catchUp(): string {
|
|
127
|
+
return ref.catchUp;
|
|
128
|
+
},
|
|
129
|
+
records,
|
|
130
|
+
events: init.events === true,
|
|
131
|
+
topic: ref.topic,
|
|
132
|
+
paramsOf: (row: Row): ChannelParams<K> | null => {
|
|
133
|
+
const params: Partial<Record<K, string>> = {};
|
|
134
|
+
for (const param of ref.params) {
|
|
135
|
+
const value = Object.hasOwn(row, param) ? row[param] : undefined;
|
|
136
|
+
if (typeof value !== 'string' || !SEGMENT.test(value)) return null;
|
|
137
|
+
params[param] = value;
|
|
138
|
+
}
|
|
139
|
+
return params as ChannelParams<K>;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
registerChannel(declared);
|
|
143
|
+
return declared;
|
|
144
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** The policy's display label, `null` for a channel any socket may join. */
|
|
17
|
+
readonly policy: string | null;
|
|
18
|
+
/** Every permission the policy asserts, flattened — what a report matches a grant against. */
|
|
19
|
+
readonly permissions: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function describeChannels(): readonly ChannelDescription[] {
|
|
23
|
+
return registeredChannels().map((declared) => ({
|
|
24
|
+
name: declared.name,
|
|
25
|
+
params: [...declared.params],
|
|
26
|
+
catchUp: declared.catchUp,
|
|
27
|
+
records: declared.records.map((projection) => projection.type).sort(),
|
|
28
|
+
events: declared.events,
|
|
29
|
+
policy: declared.policy === undefined ? null : policyCapability(declared.policy),
|
|
30
|
+
permissions:
|
|
31
|
+
declared.policy === undefined ? [] : [...policyPermissions(declared.policy)].sort(),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
@@ -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,116 @@
|
|
|
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 { 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
|
+
let frames = 0;
|
|
63
|
+
let updates: ReturnType<typeof updatesFor>;
|
|
64
|
+
try {
|
|
65
|
+
updates = updatesFor(channels, change);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.warn('channel.records_unkeyed', {
|
|
68
|
+
table: change.entity,
|
|
69
|
+
error: renderThrowable(error),
|
|
70
|
+
});
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
for (const update of updates) {
|
|
74
|
+
const open = this.#byTopic.get(update.topic);
|
|
75
|
+
if (open === undefined) continue;
|
|
76
|
+
const entry = open.ring.append(update.adopt, update.remove, change.write);
|
|
77
|
+
const frame = renderRecords(update.topic, open.ring.epoch, entry);
|
|
78
|
+
frames += this.#sockets.deliverRecords(update.topic, open.ring.epoch, frame);
|
|
79
|
+
}
|
|
80
|
+
return frames;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A (re)subscribe `since` a position: every frame after it from the ring, or — when the ring
|
|
85
|
+
* cannot prove it holds them all, or the epoch moved — one `replay-gap`.
|
|
86
|
+
*
|
|
87
|
+
* A FRESH seat with no `since` is a `replay-gap` too. The client's first read of these rows went
|
|
88
|
+
* over HTTP, on another connection, before this seat existed, so a commit between the two
|
|
89
|
+
* reached nobody — and nothing the client holds can name it. The seat is the one point after
|
|
90
|
+
* which every commit is a frame, so the re-read is told to start from here. A repeated `add` on
|
|
91
|
+
* a socket already seated (the presence beat), or a channel with no records, is sent nothing.
|
|
92
|
+
*/
|
|
93
|
+
resume(socket: SyncSocket, topic: string, since: ChannelSince | undefined, fresh: boolean): void {
|
|
94
|
+
const open = this.#byTopic.get(topic);
|
|
95
|
+
if (open === undefined) return;
|
|
96
|
+
// An events-only channel (typing, a cursor) carries no rows, so there is nothing to re-read.
|
|
97
|
+
if (since === undefined && (!fresh || open.target.channel.records.length === 0)) return;
|
|
98
|
+
const entries = since === undefined ? null : open.ring.since(since);
|
|
99
|
+
if (entries === null) {
|
|
100
|
+
const frame: ReplayGapFrame = {
|
|
101
|
+
type: 'replay-gap',
|
|
102
|
+
v: PROTOCOL_VERSION,
|
|
103
|
+
channel: topic,
|
|
104
|
+
epoch: open.ring.epoch,
|
|
105
|
+
};
|
|
106
|
+
if (!socket.send(frame)) socket.gaps.set(topic, open.ring.epoch);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!socket.send(renderRecords(topic, open.ring.epoch, entry))) {
|
|
111
|
+
socket.gaps.set(topic, open.ring.epoch);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A committed change → the record updates each declared channel owes its subscribers. Pure: no
|
|
2
|
+
// socket, no seq, no policy — `ChannelHub` does those per node and per socket. The only derivation
|
|
3
|
+
// of "which channel carries this row" lives here, so a write never names a channel (axiom 2).
|
|
4
|
+
|
|
5
|
+
import type { Row } from '@ultimat3/core/page';
|
|
6
|
+
import type { RecordProjection } from '@ultimat3/entity/record';
|
|
7
|
+
import type { ChangeEvent } from './changefeed';
|
|
8
|
+
import type { Channel, Topic } from './channel-decl';
|
|
9
|
+
import type { RecordPart } from './channel-ring';
|
|
10
|
+
|
|
11
|
+
/** One topic's share of one change. `row` is what a per-row policy decides on. */
|
|
12
|
+
export interface TopicUpdate {
|
|
13
|
+
readonly channel: Channel;
|
|
14
|
+
readonly topic: Topic;
|
|
15
|
+
readonly params: Readonly<Record<string, string>>;
|
|
16
|
+
readonly adopt: readonly RecordPart[];
|
|
17
|
+
readonly remove: readonly { readonly type: string; readonly key: string }[];
|
|
18
|
+
readonly row: Row;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `change.entity` is the replicated RELATION name (`pg-replication.ts` reads it off the Relation
|
|
23
|
+
* message), so it is matched against each projection's `table`, never its `type`.
|
|
24
|
+
*
|
|
25
|
+
* - insert/update: the new row is adopted on the topic its params name.
|
|
26
|
+
* - an update that moved the row to other params (`orgId` changed): a remove on the OLD topic too.
|
|
27
|
+
* - delete: a remove on the topic the old image names — which needs the params columns in the
|
|
28
|
+
* `before` image, i.e. `REPLICA IDENTITY FULL`; a key-only image names no topic and is skipped.
|
|
29
|
+
*/
|
|
30
|
+
export function updatesFor(channels: Iterable<Channel>, change: ChangeEvent): TopicUpdate[] {
|
|
31
|
+
const updates: TopicUpdate[] = [];
|
|
32
|
+
for (const channel of channels) {
|
|
33
|
+
const projection = channel.records.find((candidate) => candidate.table === change.entity);
|
|
34
|
+
if (projection === undefined) continue;
|
|
35
|
+
const after = change.op === 'delete' ? null : change.after;
|
|
36
|
+
const before = change.before;
|
|
37
|
+
const afterParams = after === null ? null : channel.paramsOf(after);
|
|
38
|
+
const beforeParams = before === null ? null : channel.paramsOf(before);
|
|
39
|
+
const afterTopic = afterParams === null ? null : channel.topic(afterParams);
|
|
40
|
+
const beforeTopic = beforeParams === null ? null : channel.topic(beforeParams);
|
|
41
|
+
|
|
42
|
+
if (after !== null && afterParams !== null && afterTopic !== null) {
|
|
43
|
+
updates.push({
|
|
44
|
+
channel,
|
|
45
|
+
topic: afterTopic,
|
|
46
|
+
params: afterParams,
|
|
47
|
+
adopt: [{ type: projection.type, key: projection.key(after), row: after }],
|
|
48
|
+
remove: [],
|
|
49
|
+
row: after,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
before !== null &&
|
|
54
|
+
beforeParams !== null &&
|
|
55
|
+
beforeTopic !== null &&
|
|
56
|
+
beforeTopic !== afterTopic
|
|
57
|
+
) {
|
|
58
|
+
updates.push(removal(channel, projection, beforeTopic, beforeParams, before));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return updates;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removal(
|
|
65
|
+
channel: Channel,
|
|
66
|
+
projection: RecordProjection,
|
|
67
|
+
topic: Topic,
|
|
68
|
+
params: Readonly<Record<string, string>>,
|
|
69
|
+
row: Row,
|
|
70
|
+
): TopicUpdate {
|
|
71
|
+
return {
|
|
72
|
+
channel,
|
|
73
|
+
topic,
|
|
74
|
+
params,
|
|
75
|
+
adopt: [],
|
|
76
|
+
remove: [{ type: projection.type, key: projection.key(row) }],
|
|
77
|
+
row,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A channel's CLIENT half: its name, params and catch-up read, and the one topic builder — no
|
|
2
|
+
// entity, no policy, no registration. An island holds this; the server declares the same ref's
|
|
3
|
+
// records and policy with `channel(ref, { … })`, so name and params are written once and a browser
|
|
4
|
+
// chunk never carries `@ultimat3/entity`.
|
|
5
|
+
|
|
6
|
+
import { invariant } from '@ultimat3/core/page';
|
|
7
|
+
import { TopicForbiddenError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** Branded so a raw string can never be published to; `topic()` is the only constructor. */
|
|
10
|
+
export type Topic = string & { readonly __ultimateTopic: unique symbol };
|
|
11
|
+
|
|
12
|
+
export const SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
13
|
+
|
|
14
|
+
/** `topic('org', orgId, 'cursors')` -> `org.<orgId>.cursors`. Segments are validated, never escaped. */
|
|
15
|
+
export function topic(...parts: readonly (string | number)[]): Topic {
|
|
16
|
+
const segments = parts.map((part) => String(part));
|
|
17
|
+
for (const segment of segments) {
|
|
18
|
+
if (!SEGMENT.test(segment)) {
|
|
19
|
+
throw new TopicForbiddenError({
|
|
20
|
+
topic: segments.join('.'),
|
|
21
|
+
actorId: null,
|
|
22
|
+
reason: `segment "${segment}" must match ${SEGMENT.source} (dots and wildcards are reserved)`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return segments.join('.') as Topic;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ChannelParams<K extends string> = Readonly<Record<K, string>>;
|
|
30
|
+
|
|
31
|
+
export interface ChannelRefInit<K extends string> {
|
|
32
|
+
/** Ordered param names. Each becomes one topic segment, so each value is a segment-safe string. */
|
|
33
|
+
readonly params: readonly K[];
|
|
34
|
+
/**
|
|
35
|
+
* The read a client re-runs on `replay-gap` or a new epoch — a query, by name. Read on every
|
|
36
|
+
* access, never at declaration: `registerQueries()` stamps a declared query's name at boot.
|
|
37
|
+
*/
|
|
38
|
+
readonly catchUp: { readonly name: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a browser subscribes by — `useChannel(ref, params)`. */
|
|
42
|
+
export interface ChannelHandle<K extends string = string> {
|
|
43
|
+
readonly kind: 'channel-ref';
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly params: readonly K[];
|
|
46
|
+
readonly catchUp: string;
|
|
47
|
+
/** The topic for one set of params — the one spelling both halves use. */
|
|
48
|
+
topic(params: ChannelParams<K>): Topic;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function refuseChannel(name: string, detail: string, fix: string): never {
|
|
52
|
+
invariant(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Refuses a bad name or a repeated param, and builds the ref. `channel()` is built on this. */
|
|
56
|
+
export function channelRef<const K extends string>(
|
|
57
|
+
name: string,
|
|
58
|
+
init: ChannelRefInit<K>,
|
|
59
|
+
): ChannelHandle<K> {
|
|
60
|
+
if (!SEGMENT.test(name)) {
|
|
61
|
+
refuseChannel(
|
|
62
|
+
name,
|
|
63
|
+
`the name must match ${SEGMENT.source}`,
|
|
64
|
+
`rename it, e.g. channel('org-feed', …)`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (new Set(init.params).size !== init.params.length) {
|
|
68
|
+
refuseChannel(name, 'a param is listed twice', 'list each param once in params: [...]');
|
|
69
|
+
}
|
|
70
|
+
// `Object.hasOwn`, never a bare `params[param]`: params arrive off a subscribe frame, and an
|
|
71
|
+
// inherited member would spell a topic nobody declared. Absent is `''`, which `topic()` refuses.
|
|
72
|
+
const topicOf = (params: ChannelParams<K>): Topic =>
|
|
73
|
+
topic(name, ...init.params.map((param) => (Object.hasOwn(params, param) ? params[param] : '')));
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
kind: 'channel-ref' as const,
|
|
76
|
+
name,
|
|
77
|
+
params: init.params,
|
|
78
|
+
get catchUp(): string {
|
|
79
|
+
return init.catchUp.name;
|
|
80
|
+
},
|
|
81
|
+
topic: topicOf,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Every `channel()` declared in this process, keyed by name — the realtime twin of the entity and
|
|
2
|
+
// query registries. `channel()` registers itself, so a host builds `new ChannelHub()` with no list
|
|
3
|
+
// and the manifest reads the same table (`channel-describe.ts`); a second declaration of one name
|
|
4
|
+
// is refused. Browser-safe: `channel()` runs in islands too.
|
|
5
|
+
|
|
6
|
+
import { invariant } from '@ultimat3/core';
|
|
7
|
+
import type { Channel } from './channel-decl';
|
|
8
|
+
|
|
9
|
+
const channels = new Map<string, Channel>();
|
|
10
|
+
|
|
11
|
+
export function registerChannel(declared: Channel): Channel {
|
|
12
|
+
invariant(
|
|
13
|
+
!channels.has(declared.name),
|
|
14
|
+
'X_CHANNEL_DECLARATION_INVALID',
|
|
15
|
+
`two channel() declarations share the name "${declared.name}"`,
|
|
16
|
+
'rename one of them: a channel name is its topic prefix, so it must be unique per app',
|
|
17
|
+
);
|
|
18
|
+
channels.set(declared.name, declared);
|
|
19
|
+
return declared;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The declaration of that name, or `undefined`. A `Map`, so a prototype member is never one. */
|
|
23
|
+
export function getChannel(name: string): Channel | undefined {
|
|
24
|
+
return channels.get(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sorted by name: a projection of the registry is a build input and must diff cleanly. */
|
|
28
|
+
export function registeredChannels(): readonly Channel[] {
|
|
29
|
+
return [...channels.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Test seam. Production code never unregisters a channel. */
|
|
33
|
+
export function clearChannels(): void {
|
|
34
|
+
channels.clear();
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One ring entry → the `records` frame every member of its topic receives. The same frame for all
|
|
2
|
+
// of them: a channel's scope is its params, decided once at subscribe, never re-decided per row.
|
|
3
|
+
|
|
4
|
+
import type { Row } from '@ultimat3/core/page';
|
|
5
|
+
import type { RecordsEntry } from './channel-ring';
|
|
6
|
+
import type { ChannelAdopt, ChannelRecordsFrame, ChannelRemove } from './channel-wire';
|
|
7
|
+
import { PROTOCOL_VERSION } from './sync-protocol';
|
|
8
|
+
|
|
9
|
+
export function renderRecords(
|
|
10
|
+
topic: string,
|
|
11
|
+
epoch: string,
|
|
12
|
+
entry: RecordsEntry,
|
|
13
|
+
): ChannelRecordsFrame {
|
|
14
|
+
// Null-prototype maps: a record type and a record key are data, and `'__proto__'` stays a key.
|
|
15
|
+
const adopt = Object.create(null) as Record<string, Record<string, Row>>;
|
|
16
|
+
const remove = Object.create(null) as Record<string, string[]>;
|
|
17
|
+
for (const part of entry.adopt) {
|
|
18
|
+
const byKey = adopt[part.type] ?? (Object.create(null) as Record<string, Row>);
|
|
19
|
+
byKey[part.key] = part.row;
|
|
20
|
+
adopt[part.type] = byKey;
|
|
21
|
+
}
|
|
22
|
+
for (const part of entry.remove) {
|
|
23
|
+
const keys = remove[part.type] ?? [];
|
|
24
|
+
keys.push(part.key);
|
|
25
|
+
remove[part.type] = keys;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
type: 'records',
|
|
29
|
+
v: PROTOCOL_VERSION,
|
|
30
|
+
channel: topic,
|
|
31
|
+
seq: entry.seq,
|
|
32
|
+
epoch,
|
|
33
|
+
...(entry.adopt.length === 0 ? {} : { adopt: adopt as ChannelAdopt }),
|
|
34
|
+
...(entry.remove.length === 0 ? {} : { remove: remove as ChannelRemove }),
|
|
35
|
+
...(entry.write === undefined ? {} : { write: entry.write }),
|
|
36
|
+
};
|
|
37
|
+
}
|