@ultimat3/realtime 27.4.0 → 27.5.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/README.md +35 -1
- package/package.json +5 -5
- package/src/channel-publish.ts +75 -0
- package/src/channel.ts +5 -7
- package/src/server.ts +6 -0
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ class — stay on `.`.
|
|
|
72
72
|
|
|
73
73
|
| Concern | Entry | Export |
|
|
74
74
|
|---|---|---|
|
|
75
|
-
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
|
|
75
|
+
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry`; `publishChannelEvent` — a channel event from a job or an action — with the boot's `setChannelTransport` and the test seam `resetChannelTransport` |
|
|
76
76
|
| tier 2 | `./server` | `LiveQueryRegistry`, `memoryChangeFeed()`, `postgresChangeFeed()`, `selectChangeFeed`, `changeFeedReplicator`, `postgresAdvisoryLock()`, `matcherFor` |
|
|
77
77
|
| replication | `./server` | `parsePgUrl`, `bunPgStream`, `entityRow`, `changeLsn`, `commitPositionOf` |
|
|
78
78
|
| the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). `ChangeEvent.table` is the entity's TABLE on it as on the WAL decoder and `recordPublisher`, so an `entity(name, { table })` reaches channels and live windows (25.0.0). Moved from `@ultimat3/testing`, which no longer re-exports it |
|
|
@@ -137,6 +137,40 @@ leaves `joining` on the node's first answer; nothing waits for app traffic.
|
|
|
137
137
|
A channel declared with **neither** `records` nor `events` carries nothing, so nothing answers its
|
|
138
138
|
join and it reads `joining` for as long as it is held.
|
|
139
139
|
|
|
140
|
+
## Channel events from a job or an action
|
|
141
|
+
|
|
142
|
+
The `ChannelHub` lives in the `sync` role. Everything else publishes an event with
|
|
143
|
+
`publishChannelEvent` (`./server`), on the bus the boot installed for the process — every role's
|
|
144
|
+
`startServices` calls `setChannelTransport` with the transport `selectTransport` chose.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { allow } from '@ultimat3/policy';
|
|
148
|
+
import { channel } from '@ultimat3/realtime';
|
|
149
|
+
import { publishChannelEvent } from '@ultimat3/realtime/server';
|
|
150
|
+
|
|
151
|
+
// Events only: no `records`, so nothing here reads the change feed and no replicator is needed.
|
|
152
|
+
export const orgFeed = channel('org-feed', {
|
|
153
|
+
params: ['orgId'],
|
|
154
|
+
catchUp: { name: 'orgFeed' },
|
|
155
|
+
events: true,
|
|
156
|
+
policy: allow('public'),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// In a job step or an action handler, after the write committed.
|
|
160
|
+
export const announce = (orgId: string, id: string): Promise<void> =>
|
|
161
|
+
publishChannelEvent(orgFeed, { orgId }, { kind: 'changed', id });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
| Fact | Rule |
|
|
165
|
+
|---|---|
|
|
166
|
+
| who delivers | every `sync` node bridged to that topic, to its members' `onEvent`. Under `x dev` the node is in this process and shares the bus; across processes the bus is NATS (`realtime.transport: 'nats'`) |
|
|
167
|
+
| after COMMIT | nothing here can see a transaction: an event sent from inside one is on every page before a rollback |
|
|
168
|
+
| at-most-once | never stored, never replayed — a member that was offline does not get it. Send "re-read", never state. A bus that refuses the send rejects (`X_TRANSPORT_UNAVAILABLE`); catch it where an event is not worth failing the write for |
|
|
169
|
+
| nothing booted | a unit test or a bare script publishes onto a heap bus nobody hears, and does not throw. A test that wants delivery calls `setChannelTransport(bus)` with the bus its `ChannelHub` holds, and `resetChannelTransport()` after |
|
|
170
|
+
| `realtime.enabled: false` | still safe to call: the bus exists, no node subscribes |
|
|
171
|
+
| refusals | `X_CHANNEL_DECLARATION_INVALID` on a channel declared without `events: true`; `X_TOPIC_FORBIDDEN` for a param that is no topic segment |
|
|
172
|
+
| no replicator | a node that serves no `live: true` query and no `records` channel reads no change, boots `live=none` on a real database, and is never `X_REALTIME_TOPOLOGY` |
|
|
173
|
+
|
|
140
174
|
## Channel records with no replicator
|
|
141
175
|
|
|
142
176
|
A channel's `records` come off the change bus every sync node reads. The WAL replicator fills it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.5.0",
|
|
4
4
|
"description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/core": "27.
|
|
44
|
-
"@ultimat3/entity": "27.
|
|
45
|
-
"@ultimat3/policy": "27.
|
|
46
|
-
"@ultimat3/query": "27.
|
|
43
|
+
"@ultimat3/core": "27.5.0",
|
|
44
|
+
"@ultimat3/entity": "27.5.0",
|
|
45
|
+
"@ultimat3/policy": "27.5.0",
|
|
46
|
+
"@ultimat3/query": "27.5.0",
|
|
47
47
|
"nats": "2.29.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A channel `events` frame on the bus, from a process that serves no socket: `publishChannelEvent`
|
|
2
|
+
// is what a job or an action calls. The `ChannelHub` lives in the `sync` role alone, so until this
|
|
3
|
+
// seam a worker had no way to reach one, and an events-only channel could be fed only by a socket.
|
|
4
|
+
// The frame and its subject are spelled here once; the hub's own `emit` publishes through them.
|
|
5
|
+
|
|
6
|
+
import { assertCoded } from '@ultimat3/core';
|
|
7
|
+
import type { Channel, Topic } from './channel-decl';
|
|
8
|
+
import type { ChannelEventsFrame } from './channel-wire';
|
|
9
|
+
import { InProcessTransport, type Transport } from './fanout';
|
|
10
|
+
import type { JsonObject } from './json';
|
|
11
|
+
import { PROTOCOL_VERSION } from './sync-protocol';
|
|
12
|
+
|
|
13
|
+
const CHANNEL_SUBJECT_PREFIX = 'x.channel';
|
|
14
|
+
|
|
15
|
+
/** The transport subject one topic's `events` frames ride — what a node's bridge subscribes to. */
|
|
16
|
+
export const channelSubject = (name: Topic): string => `${CHANNEL_SUBJECT_PREFIX}.${name}`;
|
|
17
|
+
|
|
18
|
+
/** One `events` frame onto `transport`: the single encoding, for the hub and for a publisher. */
|
|
19
|
+
export async function sendChannelEvent(
|
|
20
|
+
transport: Transport,
|
|
21
|
+
name: Topic,
|
|
22
|
+
event: JsonObject,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
|
|
25
|
+
await transport.publish(channelSubject(name), JSON.stringify(frame));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The bus this process publishes on. A heap bus nobody subscribes to until a boot installs the
|
|
30
|
+
* real one — `jobs`' `setEventBus` arrangement — so a unit test, or a script that booted nothing,
|
|
31
|
+
* publishes to nobody instead of throwing in the middle of the write it follows.
|
|
32
|
+
*/
|
|
33
|
+
let ambient: Transport = new InProcessTransport();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Called by the boot (`startServices`) with the transport `selectTransport` chose, in EVERY role:
|
|
37
|
+
* the same object the `sync` role's hub subscribes on, so under `x dev` a publish reaches this
|
|
38
|
+
* process's own sockets and under NATS every node's. Answers the release, which puts a fresh heap
|
|
39
|
+
* bus back only while this install is still the current one.
|
|
40
|
+
*/
|
|
41
|
+
export function setChannelTransport(transport: Transport): () => void {
|
|
42
|
+
ambient = transport;
|
|
43
|
+
return () => {
|
|
44
|
+
if (ambient === transport) resetChannelTransport();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Test seam: back to a fresh heap bus, the state a process boots in. */
|
|
49
|
+
export function resetChannelTransport(): void {
|
|
50
|
+
ambient = new InProcessTransport();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An ephemeral event to every member of `declared`'s topic for `params`, on every node — from any
|
|
55
|
+
* process the framework booted. Never stored and never replayed: a member that was offline does not
|
|
56
|
+
* get it, so an event says "re-read", never "here is the state".
|
|
57
|
+
*
|
|
58
|
+
* **Call it after the transaction commits.** Nothing here can see a transaction: an event sent
|
|
59
|
+
* from inside one is on every page before a rollback. At-most-once — a bus that refuses the send
|
|
60
|
+
* rejects (`X_TRANSPORT_UNAVAILABLE` from NATS), and the caller decides whether that is worth
|
|
61
|
+
* failing for.
|
|
62
|
+
*/
|
|
63
|
+
export async function publishChannelEvent<K extends string>(
|
|
64
|
+
declared: Channel<K>,
|
|
65
|
+
params: Readonly<Record<K, string>>,
|
|
66
|
+
event: JsonObject,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
assertCoded(
|
|
69
|
+
declared.events,
|
|
70
|
+
'X_CHANNEL_DECLARATION_INVALID',
|
|
71
|
+
`channel("${declared.name}") declares no events, so nothing may publish one on it`,
|
|
72
|
+
`declare it with events: true: channel('${declared.name}', { …, events: true })`,
|
|
73
|
+
);
|
|
74
|
+
await sendChannelEvent(ambient, declared.topic(params), event);
|
|
75
|
+
}
|
package/src/channel.ts
CHANGED
|
@@ -18,8 +18,9 @@ import { type Bridge, unsubscribeWhenOpen } from './channel-bridge';
|
|
|
18
18
|
import type { Channel, Topic } from './channel-decl';
|
|
19
19
|
import { DenialLatch } from './channel-latch';
|
|
20
20
|
import { ChannelLogs, type ChannelTopic } from './channel-logs';
|
|
21
|
+
import { channelSubject, sendChannelEvent } from './channel-publish';
|
|
21
22
|
import { getChannel, registeredChannels } from './channel-registry';
|
|
22
|
-
import type {
|
|
23
|
+
import type { ChannelSubscribeTarget } from './channel-wire';
|
|
23
24
|
import {
|
|
24
25
|
isPolicyDenial,
|
|
25
26
|
SubscriptionLimitError,
|
|
@@ -29,12 +30,10 @@ import {
|
|
|
29
30
|
import type { Transport } from './fanout';
|
|
30
31
|
import type { JsonObject } from './json';
|
|
31
32
|
import type { SocketRegistry, SyncSocket } from './socket';
|
|
32
|
-
import { decode
|
|
33
|
+
import { decode } from './sync-protocol';
|
|
33
34
|
|
|
34
35
|
export { type Topic, topic } from './channel-decl';
|
|
35
36
|
|
|
36
|
-
const CHANNEL_SUBJECT_PREFIX = 'x.channel';
|
|
37
|
-
|
|
38
37
|
export interface ChannelHubOptions {
|
|
39
38
|
readonly transport: Transport;
|
|
40
39
|
readonly sockets: SocketRegistry;
|
|
@@ -253,8 +252,7 @@ export class ChannelHub {
|
|
|
253
252
|
// A topic this node holds open on a channel declared without `events` carries none: a
|
|
254
253
|
// presence leave for it was an event on a channel whose members were promised records only.
|
|
255
254
|
if (this.#logs.target(name)?.channel.events === false) return;
|
|
256
|
-
|
|
257
|
-
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
|
|
255
|
+
await sendChannelEvent(this.#transport, name, event);
|
|
258
256
|
}
|
|
259
257
|
|
|
260
258
|
/** The declaration and params a topic joined on this node resolves to — `undefined` if none. */
|
|
@@ -448,7 +446,7 @@ export class ChannelHub {
|
|
|
448
446
|
async #open(name: Topic, bridge: Bridge): Promise<void> {
|
|
449
447
|
// Published into the bridge before it is awaited: that is what makes a second subscriber join
|
|
450
448
|
// this open instead of starting a second one the table can never reach again.
|
|
451
|
-
bridge.sub ??= this.#transport.subscribe(
|
|
449
|
+
bridge.sub ??= this.#transport.subscribe(channelSubject(name), (payload) => {
|
|
452
450
|
this.#sockets.deliver(name, decode(payload));
|
|
453
451
|
});
|
|
454
452
|
await bridge.sub;
|
package/src/server.ts
CHANGED
|
@@ -44,6 +44,12 @@ export {
|
|
|
44
44
|
DEFAULT_MAX_TOPICS_PER_NODE,
|
|
45
45
|
} from './channel';
|
|
46
46
|
export { type ChannelDescription, describeChannels } from './channel-describe';
|
|
47
|
+
// What a job or an action publishes an event with: the process's own bus, no hub needed.
|
|
48
|
+
export {
|
|
49
|
+
publishChannelEvent,
|
|
50
|
+
resetChannelTransport,
|
|
51
|
+
setChannelTransport,
|
|
52
|
+
} from './channel-publish';
|
|
47
53
|
export { RealtimeTopologyError } from './errors';
|
|
48
54
|
export {
|
|
49
55
|
InProcessTransport,
|