@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.
Files changed (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -0,0 +1,87 @@
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
+ // A truncate names no row, so it routes to no topic here: `ChannelLogs` announces a gap on every
33
+ // open topic of every channel carrying the relation instead (`truncatedTopics`).
34
+ if (change.op === 'truncate') return updates;
35
+ for (const channel of channels) {
36
+ const projection = channel.records.find((candidate) => candidate.table === change.entity);
37
+ if (projection === undefined) continue;
38
+ const after = change.op === 'delete' ? null : change.after;
39
+ const before = change.before;
40
+ const afterParams = after === null ? null : channel.paramsOf(after);
41
+ const beforeParams = before === null ? null : channel.paramsOf(before);
42
+ const afterTopic = afterParams === null ? null : channel.topic(afterParams);
43
+ const beforeTopic = beforeParams === null ? null : channel.topic(beforeParams);
44
+
45
+ if (after !== null && afterParams !== null && afterTopic !== null) {
46
+ updates.push({
47
+ channel,
48
+ topic: afterTopic,
49
+ params: afterParams,
50
+ adopt: [{ type: projection.type, key: projection.key(after), row: after }],
51
+ remove: [],
52
+ row: after,
53
+ });
54
+ }
55
+ if (
56
+ before !== null &&
57
+ beforeParams !== null &&
58
+ beforeTopic !== null &&
59
+ beforeTopic !== afterTopic
60
+ ) {
61
+ updates.push(removal(channel, projection, beforeTopic, beforeParams, before));
62
+ }
63
+ }
64
+ return updates;
65
+ }
66
+
67
+ function removal(
68
+ channel: Channel,
69
+ projection: RecordProjection,
70
+ topic: Topic,
71
+ params: Readonly<Record<string, string>>,
72
+ row: Row,
73
+ ): TopicUpdate {
74
+ return {
75
+ channel,
76
+ topic,
77
+ params,
78
+ adopt: [],
79
+ remove: [{ type: projection.type, key: projection.key(row) }],
80
+ row,
81
+ };
82
+ }
83
+
84
+ /** The channels whose records a truncate of `table` wiped: every one listing that relation. */
85
+ export function carriesTable(channel: Channel, table: string): boolean {
86
+ return channel.records.some((projection) => projection.table === table);
87
+ }
@@ -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
+ }
@@ -0,0 +1,75 @@
1
+ // One channel topic's record log on THIS node: the epoch, the next seq, and a bounded ring of the
2
+ // last frames' contents so a resubscribe `since` a recent seq replays instead of re-reading.
3
+ // Seq is minted here — at the delivering node — because a records frame never crosses the bus.
4
+
5
+ import { finiteOption, type Row } from '@ultimat3/core';
6
+ import type { ChannelSince } from './channel-wire';
7
+
8
+ /** One committed change on one topic, before it is rendered for a particular socket. */
9
+ export interface RecordsEntry {
10
+ readonly seq: number;
11
+ readonly adopt: readonly RecordPart[];
12
+ readonly remove: readonly { readonly type: string; readonly key: string }[];
13
+ /** The write that produced the change (`ChangeEvent.write`), kept so a replay still names it. */
14
+ readonly write?: string;
15
+ }
16
+
17
+ export interface RecordPart {
18
+ readonly type: string;
19
+ readonly key: string;
20
+ readonly row: Row;
21
+ }
22
+
23
+ /** Frames a ring keeps per topic. A resume further back than this is answered `replay-gap`. */
24
+ export const DEFAULT_CHANNEL_RING = 256;
25
+
26
+ export class ChannelRing {
27
+ /**
28
+ * New per ring, never per hub: a topic whose last subscriber left drops its ring, and the next
29
+ * one restarts seq at 1 — under the SAME epoch a client resuming `since: 50` would read seq 1..49
30
+ * as duplicates and silently drop them. A fresh epoch makes that a reset instead.
31
+ */
32
+ readonly epoch: string;
33
+ readonly #capacity: number;
34
+ readonly #entries: RecordsEntry[] = [];
35
+ #seq = 0;
36
+
37
+ constructor(epoch: string, capacity: number = DEFAULT_CHANNEL_RING) {
38
+ this.epoch = epoch;
39
+ this.#capacity = finiteOption('ChannelRing', 'capacity', capacity);
40
+ }
41
+
42
+ /** The seq the last `append` minted; 0 before the first. */
43
+ get seq(): number {
44
+ return this.#seq;
45
+ }
46
+
47
+ append(
48
+ adopt: readonly RecordPart[],
49
+ remove: RecordsEntry['remove'],
50
+ write?: string,
51
+ ): RecordsEntry {
52
+ this.#seq += 1;
53
+ const entry: RecordsEntry = {
54
+ seq: this.#seq,
55
+ adopt,
56
+ remove,
57
+ ...(write === undefined ? {} : { write }),
58
+ };
59
+ this.#entries.push(entry);
60
+ if (this.#entries.length > this.#capacity) this.#entries.shift();
61
+ return entry;
62
+ }
63
+
64
+ /**
65
+ * Every entry after `since.seq`, oldest first — or `null` when the ring cannot prove it holds
66
+ * all of them: another epoch, a seq from the future, or one older than the ring's oldest.
67
+ */
68
+ since(since: ChannelSince): readonly RecordsEntry[] | null {
69
+ if (since.epoch !== this.epoch || since.seq > this.#seq || since.seq < 0) return null;
70
+ if (since.seq === this.#seq) return [];
71
+ const oldest = this.#entries[0];
72
+ if (oldest === undefined || oldest.seq > since.seq + 1) return null;
73
+ return this.#entries.filter((entry) => entry.seq > since.seq);
74
+ }
75
+ }
@@ -0,0 +1,66 @@
1
+ // The three channel frames a node sends, and the subscribe target a client sends — browser-safe
2
+ // types, members of `sync-protocol.ts`'s `Frame` union since protocol 3. `records` writes the page's store; `events` never does; `replay-gap` is the server's
3
+ // verdict that this socket lost a `records` frame and must re-read the channel's catch-up query.
4
+
5
+ import type { Row } from '@ultimat3/core/page';
6
+ import type { JsonObject } from './json';
7
+
8
+ /** Where a resubscribe resumes: the last `records` frame the client applied on this channel. */
9
+ export interface ChannelSince {
10
+ readonly epoch: string;
11
+ readonly seq: number;
12
+ }
13
+
14
+ /** `subscribe.target` for a declared channel. `channel` is the declaration NAME, never a topic. */
15
+ export interface ChannelSubscribeTarget {
16
+ readonly kind: 'channel';
17
+ readonly channel: string;
18
+ readonly params: Readonly<Record<string, string>>;
19
+ readonly since?: ChannelSince;
20
+ }
21
+
22
+ /** type → record key → row: the same keyed shape as core's `RecordEnvelope.records`. */
23
+ export type ChannelAdopt = Readonly<Record<string, Readonly<Record<string, Row>>>>;
24
+ /** type → record keys to drop. */
25
+ export type ChannelRemove = Readonly<Record<string, readonly string[]>>;
26
+
27
+ /**
28
+ * One committed change on one channel. `channel` is the TOPIC (`name.param1.param2`), which the
29
+ * client derives from the same declaration. `seq` counts up by one per frame within `epoch`; a
30
+ * numeric hole is NOT a gap (a row the socket may not see is skipped for that socket) — only
31
+ * `replay-gap` is.
32
+ */
33
+ export interface ChannelRecordsFrame {
34
+ readonly type: 'records';
35
+ readonly v: number;
36
+ readonly channel: string;
37
+ readonly seq: number;
38
+ readonly epoch: string;
39
+ readonly adopt?: ChannelAdopt;
40
+ readonly remove?: ChannelRemove;
41
+ /**
42
+ * The write that produced this change: `writeDigest` of the idempotency key its request carried
43
+ * (`@ultimat3/core`), never the key. Absent for a write no page keyed — a job, a script, SQL.
44
+ * The page whose pending write this names settles it against these rows in the same
45
+ * notification, so its own echo is never painted under its own optimistic twin.
46
+ */
47
+ readonly write?: string;
48
+ }
49
+
50
+ /** Ephemeral (typing, a cursor, a toast). No seq, never written to the store, never replayed. */
51
+ export interface ChannelEventsFrame {
52
+ readonly type: 'events';
53
+ readonly v: number;
54
+ readonly channel: string;
55
+ readonly event: JsonObject;
56
+ }
57
+
58
+ /** "Your copy of this channel is wrong since `epoch`": re-run its catch-up read, then resume. */
59
+ export interface ReplayGapFrame {
60
+ readonly type: 'replay-gap';
61
+ readonly v: number;
62
+ readonly channel: string;
63
+ readonly epoch: string;
64
+ }
65
+
66
+ export type ChannelWireFrame = ChannelRecordsFrame | ChannelEventsFrame | ReplayGapFrame;