@ultimat3/realtime 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -1,15 +1,21 @@
1
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.
2
+ // the channel's own loader answered. Through `@ultimat3/query`'s `guardQuery`, the package's one
3
+ // authz seam — the same call `policy-gate.ts` makes for a live query.
4
4
 
5
- import { type Actor, type Ctx, createContext, runWithContext } from '@ultimat3/core';
6
- import { guard, QueryDeniedError } from '@ultimat3/query';
5
+ import { type Actor, type Ctx, ctxOf, runWithContext } from '@ultimat3/core';
6
+ import { guardQuery, QueryDeniedError } from '@ultimat3/query';
7
7
  import type { Channel } from './channel-decl';
8
- import { TopicForbiddenError } from './errors';
8
+ import { isTenancyDenial, TopicForbiddenError } from './errors';
9
9
 
10
10
  /**
11
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.
12
+ * came, so the caller can tell an outage from a decision — `onActorChange` suspends the topic on one.
13
+ *
14
+ * Two answers are denials before any rule runs, because neither is an outage:
15
+ * - **nobody, on a channel that decides on a row.** A loader runs AS the subscriber; with no actor
16
+ * it would run as the node, and a row is not something nobody is entitled to.
17
+ * - **a loader the tenant guard refused** (`TENANCY_DENIAL_CODES`): the actor has no org, or names
18
+ * another one. That is the verdict about a removed member, not a store that could not answer.
13
19
  */
14
20
  export async function authorizeChannel(
15
21
  channel: Channel,
@@ -18,17 +24,42 @@ export async function authorizeChannel(
18
24
  topic: string,
19
25
  params: Readonly<Record<string, string>>,
20
26
  ): 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 }));
27
+ const loader = channel.row;
28
+ if (loader === undefined) return decide(channel, ctx, actor, topic, params, null);
29
+ if (actor === null) {
30
+ throw new TopicForbiddenError({
31
+ topic,
32
+ actorId: null,
33
+ reason: `channel "${channel.name}" decides on a row, and an anonymous socket has no actor to load one as`,
34
+ });
35
+ }
36
+ // The loader and the rule run AS the subscriber, never as the node. Services are rebuilt for
37
+ // this actor by `ctxOf`, the rule `withChildContext` follows.
38
+ const scoped = subscriberContext(ctx, actor);
39
+ let row: unknown;
40
+ try {
41
+ row = await runWithContext(scoped, async () => await loader({ params, ctx: scoped }));
42
+ } catch (error) {
43
+ if (!isTenancyDenial(error)) throw error;
44
+ throw new TopicForbiddenError({
45
+ topic,
46
+ actorId: actor.id,
47
+ reason: `channel "${channel.name}" reads a row outside this actor's tenant`,
48
+ });
49
+ }
50
+ decide(channel, scoped, actor, topic, params, row);
51
+ }
52
+
53
+ function decide(
54
+ channel: Channel,
55
+ ctx: Ctx,
56
+ actor: Actor | null,
57
+ topic: string,
58
+ params: Readonly<Record<string, string>>,
59
+ row: unknown,
60
+ ): void {
30
61
  try {
31
- guard(channel.policy, { actor, input: params, row, ctx: scoped, query: channel.name }, 'live');
62
+ guardQuery(channel.policy, { actor, input: params, row, ctx, query: channel.name }, 'live');
32
63
  } catch (error) {
33
64
  if (!(error instanceof QueryDeniedError)) throw error;
34
65
  throw new TopicForbiddenError({
@@ -41,7 +72,7 @@ export async function authorizeChannel(
41
72
 
42
73
  /** The node's context, re-made for one subscriber: same deploy, role and clock, their actor. */
43
74
  function subscriberContext(node: Ctx, actor: Actor): Ctx {
44
- return createContext({
75
+ return ctxOf({
45
76
  actor,
46
77
  role: node.role,
47
78
  buildId: node.buildId,
@@ -2,7 +2,7 @@
2
2
  // flattened the way a query's is. Server-side on purpose — the policy flattening is
3
3
  // `@ultimat3/query`'s, and a browser island declaring a channel must not bundle it.
4
4
 
5
- import { policyCapability, policyPermissions } from '@ultimat3/query';
5
+ import { policyCapability, policyPermissions } from '@ultimat3/policy';
6
6
  import { registeredChannels } from './channel-registry';
7
7
 
8
8
  export interface ChannelDescription {
@@ -0,0 +1,52 @@
1
+ // The denials one socket has been answered with, latched until its session changes — so a client
2
+ // re-asking in a loop is answered without re-running the policy, and only THAT topic is refused.
3
+ // Bounded: a topic is spelled from client params, so an unbounded latch was one entry per refusal.
4
+
5
+ import { SubscriptionLimitError } from './errors';
6
+ import type { SyncSocket } from './socket';
7
+
8
+ export class DenialLatch {
9
+ /** Weakly keyed, so a socket's latch dies with it and no close path has to remember it. */
10
+ readonly #bySocket = new WeakMap<SyncSocket, Set<string>>();
11
+ readonly #max: number;
12
+ readonly #knob: string;
13
+
14
+ constructor(max: number, knob: string) {
15
+ this.#max = max;
16
+ this.#knob = knob;
17
+ }
18
+
19
+ has(socket: SyncSocket, name: string): boolean {
20
+ return this.#bySocket.get(socket)?.has(name) === true;
21
+ }
22
+
23
+ /**
24
+ * Refused BEFORE a policy runs once the latch is full: a socket that has been denied this many
25
+ * distinct topics is asking for more than it may hold, and each further ask would otherwise be
26
+ * either a retained entry or a policy pass the client schedules at will.
27
+ */
28
+ assertRoom(socket: SyncSocket): void {
29
+ if ((this.#bySocket.get(socket)?.size ?? 0) < this.#max) return;
30
+ throw new SubscriptionLimitError({
31
+ scope: 'socket',
32
+ id: socket.id,
33
+ limit: this.#max,
34
+ knob: this.#knob,
35
+ });
36
+ }
37
+
38
+ add(socket: SyncSocket, name: string): void {
39
+ const latched = this.#bySocket.get(socket) ?? new Set<string>();
40
+ if (latched.size < this.#max) this.#bySocket.set(socket, latched.add(name));
41
+ }
42
+
43
+ /** A new session re-decides everything. */
44
+ clear(socket: SyncSocket): void {
45
+ this.#bySocket.delete(socket);
46
+ }
47
+
48
+ /** Denials held for one socket — what a test bounds. */
49
+ count(socket: SyncSocket): number {
50
+ return this.#bySocket.get(socket)?.size ?? 0;
51
+ }
52
+ }
@@ -2,7 +2,7 @@
2
2
  // the change → frame path, and the resume a resubscribe `since` asks for. One per `ChannelHub`.
3
3
  // Records never cross the bus — each node reads every change itself — so seq is minted here.
4
4
 
5
- import { logger, renderThrowable, uuid } from '@ultimat3/core';
5
+ import { logger, renderThrowable, uuidV7 } from '@ultimat3/core';
6
6
  import type { ChangeEvent } from './changefeed';
7
7
  import type { Channel } from './channel-decl';
8
8
  import { carriesTable, updatesFor } from './channel-records';
@@ -22,7 +22,7 @@ export class ChannelLogs {
22
22
  readonly #sockets: SocketRegistry;
23
23
  readonly #ringSize: number | undefined;
24
24
  /** This hub's mark on every epoch it mints: a restarted node can never reuse one. */
25
- readonly #hubId = uuid();
25
+ readonly #hubId = uuidV7();
26
26
  #rings = 0;
27
27
  readonly #byTopic = new Map<
28
28
  string,
@@ -59,14 +59,14 @@ export class ChannelLogs {
59
59
  * the change is somebody else's too, and one malformed image must not stop the rest.
60
60
  */
61
61
  deliverChange(channels: Iterable<Channel>, change: ChangeEvent): number {
62
- if (change.op === 'truncate') return this.#truncated(change.entity);
62
+ if (change.op === 'truncate') return this.invalidate(change.table);
63
63
  let frames = 0;
64
64
  let updates: ReturnType<typeof updatesFor>;
65
65
  try {
66
66
  updates = updatesFor(channels, change);
67
67
  } catch (error) {
68
68
  logger.warn('channel.records_unkeyed', {
69
- table: change.entity,
69
+ table: change.table,
70
70
  error: renderThrowable(error),
71
71
  });
72
72
  return 0;
@@ -74,7 +74,12 @@ export class ChannelLogs {
74
74
  for (const update of updates) {
75
75
  const open = this.#byTopic.get(update.topic);
76
76
  if (open === undefined) continue;
77
- const entry = open.ring.append(update.adopt, update.remove, change.write);
77
+ // The change could not carry the whole row (`channel-records.ts`): members re-read it.
78
+ if (update.gap === true) {
79
+ frames += this.#reopen(update.topic, open.target);
80
+ continue;
81
+ }
82
+ const entry = open.ring.append(update.adopt, update.remove, change.write ?? undefined);
78
83
  const frame = renderRecords(update.topic, open.ring.epoch, entry);
79
84
  frames += this.#sockets.deliverRecords(update.topic, open.ring.epoch, frame);
80
85
  }
@@ -82,22 +87,33 @@ export class ChannelLogs {
82
87
  }
83
88
 
84
89
  /**
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.
90
+ * This node may have missed a change, and seq is minted HERE from the changes it sees, so the
91
+ * hole is invisible to the ring: the next change would be appended as the next seq and a
92
+ * `since` replayed across it as complete history. Every open topic that carries records — or,
93
+ * given a `table`, every one carrying THAT table — starts a new epoch and tells its members
94
+ * `replay-gap`, which the client answers by re-running the channel's catch-up read.
95
+ *
96
+ * With a table, three callers: a gap on the bus (none), a truncate (every row of it is gone, and
97
+ * no frame can say which ones a member holds), and a bulk write the in-process replicator saw
98
+ * as a fact rather than as rows (`live-replicator.ts`).
89
99
  */
90
- #truncated(table: string): number {
100
+ invalidate(table?: string): number {
91
101
  let announced = 0;
92
102
  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);
103
+ if (open.target.channel.records.length === 0) continue;
104
+ if (table !== undefined && !carriesTable(open.target.channel, table)) continue;
105
+ announced += this.#reopen(topic, open.target);
97
106
  }
98
107
  return announced;
99
108
  }
100
109
 
110
+ /** A new epoch for one topic — nothing in the old ring may be replayed — and a gap to each member. */
111
+ #reopen(topic: string, target: ChannelTopic): number {
112
+ this.#byTopic.delete(topic);
113
+ const ring = this.open(topic, target);
114
+ return this.#sockets.announceGap(topic, ring.epoch);
115
+ }
116
+
101
117
  /**
102
118
  * A (re)subscribe `since` a position: every frame after it from the ring, or — when the ring
103
119
  * cannot prove it holds them all, or the epoch moved — one `replay-gap`.
@@ -107,6 +123,10 @@ export class ChannelLogs {
107
123
  * reached nobody — and nothing the client holds can name it. The seat is the one point after
108
124
  * which every commit is a frame, so the re-read is told to start from here. A repeated `add` on
109
125
  * a socket already seated (the presence beat), or a channel with no records, is sent nothing.
126
+ *
127
+ * A `since` that is already CURRENT is answered too: the frame AT its cursor, carrying nothing.
128
+ * Sent nothing, the client had no frame to leave `joining` on — a channel that was up to date
129
+ * read as never joined. The seq is one it already holds, so its cursor does not move.
110
130
  */
111
131
  resume(socket: SyncSocket, topic: string, since: ChannelSince | undefined, fresh: boolean): void {
112
132
  const open = this.#byTopic.get(topic);
@@ -124,7 +144,8 @@ export class ChannelLogs {
124
144
  if (!socket.send(frame)) socket.gaps.set(topic, open.ring.epoch);
125
145
  return;
126
146
  }
127
- for (const entry of entries) {
147
+ const answer = entries.length > 0 ? entries : [{ seq: open.ring.seq, adopt: [], remove: [] }];
148
+ for (const entry of answer) {
128
149
  if (!socket.send(renderRecords(topic, open.ring.epoch, entry))) {
129
150
  socket.gaps.set(topic, open.ring.epoch);
130
151
  return;
@@ -2,7 +2,7 @@
2
2
  // `events: true` joins its presence set, and a roster change is one `events` frame on that topic.
3
3
  // Browser-safe — the client reads the same shape back out with `readPresence`.
4
4
 
5
- import { isJsonObject, type JsonObject } from './json';
5
+ import { isParsedJsonObject, type JsonObject } from './json';
6
6
  import { FRAME_LIMITS, type PresenceMember } from './sync-protocol';
7
7
 
8
8
  export type PresenceOp = 'join' | 'leave' | 'update' | 'sync';
@@ -60,9 +60,9 @@ export function readPresence(event: JsonObject): PresenceEvent | null {
60
60
  }
61
61
 
62
62
  function memberOf(value: unknown): PresenceMember | null {
63
- if (!isJsonObject(value)) return null;
63
+ if (!isParsedJsonObject(value)) return null;
64
64
  const { id, actorId, meta, updatedAt } = value;
65
65
  if (typeof id !== 'string' || typeof updatedAt !== 'number') return null;
66
66
  if (actorId !== null && typeof actorId !== 'string') return null;
67
- return { id, actorId, meta: isJsonObject(meta) ? meta : {}, updatedAt };
67
+ return { id, actorId, meta: isParsedJsonObject(meta) ? meta : {}, updatedAt };
68
68
  }
@@ -2,6 +2,7 @@
2
2
  // socket, no seq, no policy — `ChannelHub` does those per node and per socket. The only derivation
3
3
  // of "which channel carries this row" lives here, so a write never names a channel (axiom 2).
4
4
 
5
+ import { type Counter, counter, logger } from '@ultimat3/core';
5
6
  import type { Row } from '@ultimat3/core/page';
6
7
  import type { RecordProjection } from '@ultimat3/entity/record';
7
8
  import type { ChangeEvent } from './changefeed';
@@ -16,16 +17,35 @@ export interface TopicUpdate {
16
17
  readonly adopt: readonly RecordPart[];
17
18
  readonly remove: readonly { readonly type: string; readonly key: string }[];
18
19
  readonly row: Row;
20
+ /**
21
+ * The change could not carry this row whole (`ChangeEvent.omitted` names a column the record
22
+ * projects), so there is nothing sound to adopt: the topic's members are told to re-read.
23
+ */
24
+ readonly gap?: true;
25
+ }
26
+
27
+ const removalsSkipped: Counter = counter('channel_removals_skipped_total', {
28
+ unit: '{removal}',
29
+ description:
30
+ 'Channel record removals that could not be routed: the old row image lacked the channel params (the table is not REPLICA IDENTITY FULL), so members keep the removed record.',
31
+ });
32
+ let skipped = 0;
33
+
34
+ /** Removals this process could not route since boot — the in-process read of the counter above. */
35
+ export function skippedRemovals(): number {
36
+ return skipped;
19
37
  }
20
38
 
21
39
  /**
22
- * `change.entity` is the replicated RELATION name (`pg-replication.ts` reads it off the Relation
40
+ * `change.table` is the replicated RELATION name (`pg-replication.ts` reads it off the Relation
23
41
  * message), so it is matched against each projection's `table`, never its `type`.
24
42
  *
25
43
  * - insert/update: the new row is adopted on the topic its params name.
26
44
  * - an update that moved the row to other params (`orgId` changed): a remove on the OLD topic too.
27
45
  * - 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.
46
+ * `before` image, i.e. `REPLICA IDENTITY FULL`; a key-only image names no topic and is skipped
47
+ * — COUNTED and logged (`channel.removal_skipped`), because the members of that topic keep a
48
+ * record the database no longer has and nothing else would ever say so.
29
49
  */
30
50
  export function updatesFor(channels: Iterable<Channel>, change: ChangeEvent): TopicUpdate[] {
31
51
  const updates: TopicUpdate[] = [];
@@ -33,23 +53,34 @@ export function updatesFor(channels: Iterable<Channel>, change: ChangeEvent): To
33
53
  // open topic of every channel carrying the relation instead (`truncatedTopics`).
34
54
  if (change.op === 'truncate') return updates;
35
55
  for (const channel of channels) {
36
- const projection = channel.records.find((candidate) => candidate.table === change.entity);
56
+ const projection = channel.records.find((candidate) => candidate.table === change.table);
37
57
  if (projection === undefined) continue;
38
58
  const after = change.op === 'delete' ? null : change.after;
39
59
  const before = change.before;
40
60
  const afterParams = after === null ? null : channel.paramsOf(after);
41
61
  const beforeParams = before === null ? null : channel.paramsOf(before);
62
+ if (before !== null && beforeParams === null && channel.params.length > 0) {
63
+ skipped += 1;
64
+ removalsSkipped.add(1);
65
+ logger.warn('channel.removal_skipped', { channel: channel.name, table: change.table });
66
+ }
42
67
  const afterTopic = afterParams === null ? null : channel.topic(afterParams);
43
68
  const beforeTopic = beforeParams === null ? null : channel.topic(beforeParams);
44
69
 
45
70
  if (after !== null && afterParams !== null && afterTopic !== null) {
71
+ // An unchanged out-of-line value Postgres logged no bytes for: adopted, the partial row
72
+ // would be retained in the ring as the whole record and handed to every later joiner.
73
+ const partial = (change.omitted ?? []).some((column) =>
74
+ Object.hasOwn(projection.schema.properties ?? {}, column),
75
+ );
46
76
  updates.push({
47
77
  channel,
48
78
  topic: afterTopic,
49
79
  params: afterParams,
50
- adopt: [{ type: projection.type, key: projection.key(after), row: after }],
80
+ adopt: partial ? [] : [{ type: projection.type, key: projection.key(after), row: after }],
51
81
  remove: [],
52
82
  row: after,
83
+ ...(partial ? { gap: true as const } : {}),
53
84
  });
54
85
  }
55
86
  if (
@@ -3,7 +3,7 @@
3
3
  // records and policy with `channel(ref, { … })`, so name and params are written once and a browser
4
4
  // chunk never carries `@ultimat3/entity`.
5
5
 
6
- import { invariant } from '@ultimat3/core/page';
6
+ import { assertCoded } from '@ultimat3/core/page';
7
7
  import { TopicForbiddenError } from './errors';
8
8
 
9
9
  /** Branded so a raw string can never be published to; `topic()` is the only constructor. */
@@ -49,7 +49,7 @@ export interface ChannelHandle<K extends string = string> {
49
49
  }
50
50
 
51
51
  export function refuseChannel(name: string, detail: string, fix: string): never {
52
- invariant(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
52
+ assertCoded(false, 'X_CHANNEL_DECLARATION_INVALID', `channel("${name}"): ${detail}`, fix);
53
53
  }
54
54
 
55
55
  /** Refuses a bad name or a repeated param, and builds the ref. `channel()` is built on this. */
@@ -3,13 +3,13 @@
3
3
  // and the manifest reads the same table (`channel-describe.ts`); a second declaration of one name
4
4
  // is refused. Browser-safe: `channel()` runs in islands too.
5
5
 
6
- import { invariant } from '@ultimat3/core';
6
+ import { assertCoded } from '@ultimat3/core';
7
7
  import type { Channel } from './channel-decl';
8
8
 
9
9
  const channels = new Map<string, Channel>();
10
10
 
11
11
  export function registerChannel(declared: Channel): Channel {
12
- invariant(
12
+ assertCoded(
13
13
  !channels.has(declared.name),
14
14
  'X_CHANNEL_DECLARATION_INVALID',
15
15
  `two channel() declarations share the name "${declared.name}"`,
@@ -0,0 +1,37 @@
1
+ // The sid each of a socket's channel seats was answered under, keyed by TOPIC. A sid is client
2
+ // data: keyed by it, one socket minted an entry per fresh sid without bound, and two sids naming
3
+ // one topic aliased one membership — dropping either unsubscribed the other.
4
+
5
+ import type { Topic } from './channel-decl';
6
+ import type { SyncSocket } from './socket';
7
+
8
+ /** Weakly keyed by socket, so a socket's entries die with it; bounded by the hub's topic cap. */
9
+ export class ChannelSids {
10
+ readonly #bySocket = new WeakMap<SyncSocket, Map<Topic, string>>();
11
+
12
+ /** The latest sid wins: a repeated `add` under a new sid re-names the one seat, never adds one. */
13
+ set(socket: SyncSocket, topic: Topic, sid: string): void {
14
+ const topics = this.#bySocket.get(socket) ?? new Map<Topic, string>();
15
+ this.#bySocket.set(socket, topics.set(topic, sid));
16
+ }
17
+
18
+ /** The sid a refusal for this seat must name — `undefined` when the socket holds no such seat. */
19
+ sidOf(socket: SyncSocket, topic: Topic): string | undefined {
20
+ return this.#bySocket.get(socket)?.get(topic);
21
+ }
22
+
23
+ /** The topic currently held under `sid`. A sid an earlier `add` was re-named away from holds none. */
24
+ topicOf(socket: SyncSocket, sid: string): Topic | undefined {
25
+ for (const [topic, held] of this.#bySocket.get(socket) ?? []) if (held === sid) return topic;
26
+ return undefined;
27
+ }
28
+
29
+ delete(socket: SyncSocket, topic: Topic): void {
30
+ this.#bySocket.get(socket)?.delete(topic);
31
+ }
32
+
33
+ /** Seats recorded for one socket — what a test bounds. */
34
+ count(socket: SyncSocket): number {
35
+ return this.#bySocket.get(socket)?.size ?? 0;
36
+ }
37
+ }