serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,871 @@
1
+ /**
2
+ * AWS Lambda-flavoured {@link IrcRuntime}.
3
+ *
4
+ * Backed by DynamoDB (Connections / ChannelMeta / ChannelMembers / Nicks)
5
+ * for authoritative state and `ApiGatewayManagementApi` for cross-connection
6
+ * WebSocket fanout. Constructed fresh per `$default` Lambda invocation;
7
+ * cheap to build, no caching across events.
8
+ *
9
+ * Pattern mirrors `cf-adapter/src/cf-runtime.ts`:
10
+ * - Calls whose target *is* the bound connection shortcut through the
11
+ * injected {@link AwsRuntimeHandlers} — no APIGW round-trip.
12
+ * - Cross-connection calls hit DynamoDB (the authoritative store).
13
+ * - `send()` to a connection whose APIGW socket is gone catches
14
+ * `GoneException` and triggers {@link cleanupConnection}.
15
+ *
16
+ * Membership atomicity: JOIN/PART/KICK run as a single
17
+ * `TransactWriteItems` spanning `ChannelMembers` and
18
+ * `Connections.joinedChannels` — the two cannot diverge even under
19
+ * concurrent `$default` invocations for the same connection. A
20
+ * `TransactionConflict` (concurrent item access) is retried with a
21
+ * small linear backoff; `ADD`/`DELETE` on the String Set is naturally
22
+ * idempotent so retries never duplicate state.
23
+ */
24
+
25
+ import {
26
+ type ApiGatewayManagementApi,
27
+ GoneException,
28
+ } from '@aws-sdk/client-apigatewaymanagementapi';
29
+ import type { TransactWriteItem } from '@aws-sdk/client-dynamodb';
30
+ import type { DynamoDBDocumentClient, NativeAttributeValue } from '@aws-sdk/lib-dynamodb';
31
+ import {
32
+ DeleteCommand,
33
+ GetCommand,
34
+ PutCommand,
35
+ QueryCommand,
36
+ ScanCommand,
37
+ TransactWriteCommand,
38
+ UpdateCommand,
39
+ } from '@aws-sdk/lib-dynamodb';
40
+ import {
41
+ type ChanName,
42
+ type ChanSnapshot,
43
+ type ChannelDelta,
44
+ type ChannelModes,
45
+ type ChannelState,
46
+ type ChannelTopic,
47
+ type Clock,
48
+ type ConnId,
49
+ type ConnSnapshot,
50
+ type ConnectionState,
51
+ type Nick,
52
+ type RawLine,
53
+ type RosterEntry,
54
+ SystemClock,
55
+ emptyModes,
56
+ toSnapshot as toConnSnapshot,
57
+ } from '@serverless-ircd/irc-core';
58
+ import type { IrcRuntime } from '@serverless-ircd/irc-server';
59
+ import {
60
+ type MarshalledChannelMember,
61
+ type MarshalledChannelMeta,
62
+ type MarshalledConnection,
63
+ type MarshalledNick,
64
+ marshalChannelMember,
65
+ marshalConnection,
66
+ marshalNick,
67
+ unmarshalChannelMember,
68
+ unmarshalChannelMeta,
69
+ unmarshalConnection,
70
+ } from './serialize.js';
71
+ import type { TablesConfig } from './tables.js';
72
+
73
+ /**
74
+ * Per-connection transport callbacks the Lambda handler wires up. Mirrors
75
+ * {@link CfConnectionHandlers} in cf-adapter — same shape, same semantics.
76
+ *
77
+ * - `send(lines)` writes already-framed bytes to the bound socket.
78
+ * In a Lambda `$default` invocation this pushes into the handler's
79
+ * `received[]` (the APIGW echo back to the caller). In tests this
80
+ * is the in-memory `received` array.
81
+ * - `disconnect(reason?)` closes the bound transport. In Lambda this
82
+ * is a no-op — the canonical disconnect path is the APIGW `$disconnect`
83
+ * route, which fires after the socket is already gone.
84
+ * - `snapshot()` returns the in-memory ConnectionState the actor is
85
+ * mutating this frame. Critical so `getConnection(boundId)` returns
86
+ * the live view (and dispatch's reducer-observable effects see
87
+ * mutations made earlier in the same frame).
88
+ */
89
+ export interface AwsRuntimeHandlers {
90
+ send: (lines: RawLine[]) => void;
91
+ disconnect: (reason?: string) => void;
92
+ snapshot: () => ConnectionState | undefined;
93
+ }
94
+
95
+ /**
96
+ * Minimal port for pushing bytes to a remote WebSocket connection. Both
97
+ * `ApiGatewayManagementApi` and test doubles satisfy this structurally —
98
+ * aws-runtime does not care which it gets.
99
+ */
100
+ export interface PostToConnection {
101
+ postToConnection(input: { ConnectionId: ConnId; Data: string }): Promise<unknown>;
102
+ }
103
+
104
+ /** Constructor options for {@link AwsRuntime}. */
105
+ export interface AwsRuntimeOptions {
106
+ dynamo: DynamoDBDocumentClient;
107
+ tables: TablesConfig;
108
+ connId: ConnId;
109
+ handlers: AwsRuntimeHandlers;
110
+ /**
111
+ * Backend used by {@link send} for cross-connection fanout. May be:
112
+ * - a real `ApiGatewayManagementApi` client (production),
113
+ * - a test double that satisfies {@link PostToConnection} (integration
114
+ * tests route to other actors' `received` arrays),
115
+ * - `null` when no cross-connection send is ever needed (self-only
116
+ * unit tests). `send` to a non-self connection is a silent no-op
117
+ * in that mode — the caller is asserting on DynamoDB state only.
118
+ */
119
+ managementApi: ApiGatewayManagementApi | PostToConnection | null;
120
+ clock?: Clock;
121
+ }
122
+
123
+ /**
124
+ * AWS-flavoured {@link IrcRuntime} bound to one connection.
125
+ *
126
+ * Construct one per inbound Lambda event.
127
+ */
128
+ export class AwsRuntime implements IrcRuntime {
129
+ private readonly dynamo: DynamoDBDocumentClient;
130
+ private readonly tables: TablesConfig;
131
+ private readonly connId: ConnId;
132
+ private readonly handlers: AwsRuntimeHandlers;
133
+ private readonly managementApi: ApiGatewayManagementApi | PostToConnection | null;
134
+ private readonly clock: Clock;
135
+
136
+ constructor(opts: AwsRuntimeOptions) {
137
+ this.dynamo = opts.dynamo;
138
+ this.tables = opts.tables;
139
+ this.connId = opts.connId;
140
+ this.handlers = opts.handlers;
141
+ this.managementApi = opts.managementApi;
142
+ this.clock = opts.clock ?? SystemClock;
143
+ }
144
+
145
+ // -------------------------------------------------------------------------
146
+ // Transport
147
+ // -------------------------------------------------------------------------
148
+
149
+ async send(to: ConnId, lines: RawLine[]): Promise<void> {
150
+ if (to === this.connId) {
151
+ // Self-send goes through the handler so the actor's mutations are
152
+ // visible within the same frame (no APIGW round-trip).
153
+ this.handlers.send(lines);
154
+ return;
155
+ }
156
+ if (this.managementApi === null) {
157
+ // Cross-connection send with no backend — silent no-op. The caller
158
+ // is asserting on DynamoDB state only (e.g. a Connections row was
159
+ // written, a nick was reserved). Production never hits this path.
160
+ return;
161
+ }
162
+ const data = lines.map((l) => l.text).join('\r\n');
163
+ try {
164
+ await this.managementApi.postToConnection({ ConnectionId: to, Data: data });
165
+ } catch (err: unknown) {
166
+ if (err instanceof GoneException || isGone(err)) {
167
+ await cleanupConnection(this.dynamo, this.tables, to, this.managementApi);
168
+ return;
169
+ }
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
175
+ const key = chan.toLowerCase();
176
+ const members = await this.queryMembers(key);
177
+ for (const entry of members) {
178
+ if (entry.connectionId === except) continue;
179
+ await this.send(entry.connectionId, lines);
180
+ }
181
+ }
182
+
183
+ async disconnect(conn: ConnId, reason?: string): Promise<void> {
184
+ if (conn === this.connId) {
185
+ this.handlers.disconnect(reason);
186
+ return;
187
+ }
188
+ // Cross-connection disconnect is a no-op in 040: the Lambda runtime
189
+ // cannot directly close another connection's WebSocket. The canonical
190
+ // disconnect path is the APIGW `$disconnect` route, which fires when
191
+ // the socket is already gone. KICK/PART remove membership rows but
192
+ // do not need to force-close the socket.
193
+ }
194
+
195
+ async sendToNick(
196
+ nick: Nick,
197
+ sender: ConnId,
198
+ lines: RawLine[],
199
+ notFoundLines?: RawLine[],
200
+ ): Promise<void> {
201
+ const owner = await this.lookupNick(nick);
202
+ if (owner !== null) {
203
+ await this.send(owner, lines);
204
+ return;
205
+ }
206
+ if (notFoundLines !== undefined) {
207
+ await this.send(sender, notFoundLines);
208
+ }
209
+ }
210
+
211
+ // -------------------------------------------------------------------------
212
+ // Nick registry
213
+ // -------------------------------------------------------------------------
214
+
215
+ async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
216
+ const row = marshalNick(nick, conn, this.clock.now());
217
+ try {
218
+ await this.dynamo.send(
219
+ new PutCommand({
220
+ TableName: this.tables.Nicks,
221
+ Item: row as unknown as Record<string, NativeAttributeValue>,
222
+ ConditionExpression: 'attribute_not_exists(nickLower)',
223
+ }),
224
+ );
225
+ return { ok: true };
226
+ } catch (err: unknown) {
227
+ if (isConditionalCheckFailed(err)) {
228
+ // Re-owning by the same conn is allowed — match the in-memory
229
+ // runtime's idempotent semantics so re-reservation doesn't fail.
230
+ const existing = await this.dynamo.send(
231
+ new GetCommand({ TableName: this.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
232
+ );
233
+ if (existing.Item?.connectionId === conn) return { ok: true };
234
+ return { ok: false };
235
+ }
236
+ throw err;
237
+ }
238
+ }
239
+
240
+ async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
241
+ const reserved = await this.reserveNick(newNick, conn);
242
+ if (!reserved.ok) return false;
243
+ await this.releaseNick(oldNick);
244
+ return true;
245
+ }
246
+
247
+ async releaseNick(nick: Nick): Promise<void> {
248
+ try {
249
+ await this.dynamo.send(
250
+ new DeleteCommand({
251
+ TableName: this.tables.Nicks,
252
+ Key: { nickLower: nick.toLowerCase() },
253
+ }),
254
+ );
255
+ } catch (err: unknown) {
256
+ // Idempotent: ResourceNotFound is the only error we tolerate; the
257
+ // SDK v3 delete is already idempotent on missing rows, but defensive.
258
+ if (!isResourceNotFound(err)) throw err;
259
+ }
260
+ }
261
+
262
+ // -------------------------------------------------------------------------
263
+ // Channel ownership
264
+ // -------------------------------------------------------------------------
265
+
266
+ async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
267
+ const key = chan.toLowerCase();
268
+ // Ensure a meta row exists so getChannelSnapshot can find the channel
269
+ // even when the delta only adds memberships (common JOIN case).
270
+ await this.ensureMetaRow(chan, key);
271
+ if (delta.memberships !== undefined && delta.memberships.length > 0) {
272
+ await this.applyMembershipTransaction(key, delta.memberships);
273
+ }
274
+ if (delta.modeChanges !== undefined && delta.modeChanges.length > 0) {
275
+ const baseModes = await this.readMetaModes(key);
276
+ const nextModes = applyModeChanges(baseModes ?? emptyModes(), delta.modeChanges);
277
+ await this.updateMetaModes(key, nextModes);
278
+ }
279
+ if (delta.banMaskChanges !== undefined && delta.banMaskChanges.length > 0) {
280
+ const existing = await this.readBanMasks(key);
281
+ const set = new Set(existing);
282
+ for (const b of delta.banMaskChanges) {
283
+ if (b.type === 'add') set.add(b.mask);
284
+ else set.delete(b.mask);
285
+ }
286
+ await this.updateBanMasks(key, set);
287
+ }
288
+ if (delta.topic !== undefined) {
289
+ await this.updateTopic(key, delta.topic);
290
+ }
291
+ }
292
+
293
+ // -------------------------------------------------------------------------
294
+ // Lookups
295
+ // -------------------------------------------------------------------------
296
+
297
+ async lookupNick(nick: Nick): Promise<ConnId | null> {
298
+ const result = await this.dynamo.send(
299
+ new GetCommand({ TableName: this.tables.Nicks, Key: { nickLower: nick.toLowerCase() } }),
300
+ );
301
+ if (result.Item === undefined) return null;
302
+ return (result.Item as unknown as MarshalledNick).connectionId;
303
+ }
304
+
305
+ async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
306
+ // Self-shortcut: the actor's mutations are visible to effects running
307
+ // in the same frame (cf-runtime mirrors this).
308
+ if (conn === this.connId) {
309
+ const live = this.handlers.snapshot();
310
+ return live === undefined ? null : toConnSnapshot(live);
311
+ }
312
+ const row = await this.readConnectionRow(conn);
313
+ if (row === null) return null;
314
+ return toConnSnapshot(unmarshalConnection(row));
315
+ }
316
+
317
+ async getConnection(conn: ConnId): Promise<ConnectionState | null> {
318
+ if (conn === this.connId) {
319
+ return this.handlers.snapshot() ?? null;
320
+ }
321
+ const row = await this.readConnectionRow(conn);
322
+ if (row === null) return null;
323
+ return unmarshalConnection(row);
324
+ }
325
+
326
+ async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
327
+ const key = chan.toLowerCase();
328
+ const meta = await this.readMeta(key);
329
+ if (meta === null) return null;
330
+ const members = await this.queryMembers(key);
331
+ const chanState = unmarshalChannelMeta(
332
+ meta,
333
+ new Map(members.map((m) => [m.connectionId, unmarshalChannelMember(m)] as const)),
334
+ );
335
+ return chanStateToSnapshot(chanState);
336
+ }
337
+
338
+ async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
339
+ const key = chan.toLowerCase();
340
+ const members = await this.queryMembers(key);
341
+ const out = new Map<ConnId, ConnectionState>();
342
+ for (const m of members) {
343
+ const row = await this.readConnectionRow(m.connectionId);
344
+ if (row !== null) {
345
+ out.set(m.connectionId, unmarshalConnection(row));
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+
351
+ async listChannels(): Promise<ChanSnapshot[]> {
352
+ const result = await this.dynamo.send(new ScanCommand({ TableName: this.tables.ChannelMeta }));
353
+ const items = (result.Items ?? []) as unknown as MarshalledChannelMeta[];
354
+ const out: ChanSnapshot[] = [];
355
+ for (const meta of items) {
356
+ const members = await this.queryMembers(meta.channelName);
357
+ const chanState = unmarshalChannelMeta(
358
+ meta,
359
+ new Map(members.map((m) => [m.connectionId, unmarshalChannelMember(m)] as const)),
360
+ );
361
+ out.push(chanStateToSnapshot(chanState));
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // -------------------------------------------------------------------------
367
+ // Public helpers (re-exported surface)
368
+ // -------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Persists the supplied {@link ConnectionState} as a Connections row,
372
+ * updating `idleSince` to `Date.now()` so the TTL stays fresh on
373
+ * every `$default` invocation.
374
+ */
375
+ async persistConnectionState(state: ConnectionState): Promise<void> {
376
+ const row = marshalConnection(state, this.clock.now());
377
+ await this.dynamo.send(
378
+ new PutCommand({
379
+ TableName: this.tables.Connections,
380
+ Item: row as unknown as Record<string, NativeAttributeValue>,
381
+ }),
382
+ );
383
+ }
384
+
385
+ // -------------------------------------------------------------------------
386
+ // Internal — Connections
387
+ // -------------------------------------------------------------------------
388
+
389
+ private async readConnectionRow(conn: ConnId): Promise<MarshalledConnection | null> {
390
+ const result = await this.dynamo.send(
391
+ new GetCommand({
392
+ TableName: this.tables.Connections,
393
+ Key: { connectionId: conn },
394
+ }),
395
+ );
396
+ if (result.Item === undefined) return null;
397
+ return result.Item as unknown as MarshalledConnection;
398
+ }
399
+
400
+ // -------------------------------------------------------------------------
401
+ // Internal — Channels
402
+ // -------------------------------------------------------------------------
403
+
404
+ /**
405
+ * Atomically applies a batch of roster mutations for one channel.
406
+ * Each mutation produces:
407
+ * - a `Put`/`Delete` on `ChannelMembers` (the authoritative roster), AND
408
+ * - an `ADD`/`DELETE` on the owning connection's `joinedChannels` set.
409
+ *
410
+ * All items go into ONE `TransactWriteItems` so the two tables cannot
411
+ * diverge — even when two `$default` invocations for the same
412
+ * connection race (API Gateway may deliver a client's frames
413
+ * concurrently). ADD/DELETE on the String Set is idempotent, so the
414
+ * retry-on-conflict loop never doubles state.
415
+ *
416
+ * Per-connection updates are coalesced into a single `Update` item
417
+ * because DynamoDB rejects two transact items targeting the same key.
418
+ * Assumption: ≤ ~50 distinct connections per delta (DynamoDB caps
419
+ * TransactWriteItems at 100 items). Reducers today emit one membership
420
+ * per delta, so this is far below the ceiling.
421
+ */
422
+ private async applyMembershipTransaction(
423
+ key: string,
424
+ memberships: NonNullable<ChannelDelta['memberships']>,
425
+ ): Promise<void> {
426
+ const memberOps: Array<{ type: 'add'; entry: RosterEntry } | { type: 'remove'; conn: ConnId }> =
427
+ [];
428
+ const addsByConn = new Map<ConnId, Set<string>>();
429
+ const removesByConn = new Map<ConnId, Set<string>>();
430
+
431
+ for (const m of memberships) {
432
+ if (m.type === 'add') {
433
+ if (m.nick === undefined) continue;
434
+ const entry: RosterEntry = {
435
+ conn: m.conn,
436
+ nick: m.nick,
437
+ op: m.op ?? false,
438
+ voice: m.voice ?? false,
439
+ };
440
+ memberOps.push({ type: 'add', entry });
441
+ collectChannel(addsByConn, m.conn, key);
442
+ } else {
443
+ memberOps.push({ type: 'remove', conn: m.conn });
444
+ collectChannel(removesByConn, m.conn, key);
445
+ }
446
+ }
447
+
448
+ if (memberOps.length === 0) return;
449
+
450
+ const transactItems: TransactWriteItem[] = [];
451
+
452
+ for (const op of memberOps) {
453
+ if (op.type === 'add') {
454
+ transactItems.push({
455
+ Put: {
456
+ TableName: this.tables.ChannelMembers,
457
+ Item: marshalChannelMember(key, op.entry) as unknown as Record<
458
+ string,
459
+ NativeAttributeValue
460
+ >,
461
+ },
462
+ });
463
+ } else {
464
+ transactItems.push({
465
+ Delete: {
466
+ TableName: this.tables.ChannelMembers,
467
+ Key: {
468
+ channelName: key,
469
+ connectionId: op.conn,
470
+ } as unknown as Record<string, NativeAttributeValue>,
471
+ },
472
+ });
473
+ }
474
+ }
475
+
476
+ for (const [conn, chans] of addsByConn) {
477
+ transactItems.push({
478
+ Update: {
479
+ TableName: this.tables.Connections,
480
+ Key: { connectionId: conn } as unknown as Record<string, NativeAttributeValue>,
481
+ UpdateExpression: 'ADD joinedChannels :chans',
482
+ ExpressionAttributeValues: {
483
+ ':chans': chans as unknown as NativeAttributeValue,
484
+ },
485
+ },
486
+ });
487
+ }
488
+ for (const [conn, chans] of removesByConn) {
489
+ transactItems.push({
490
+ Update: {
491
+ TableName: this.tables.Connections,
492
+ Key: { connectionId: conn } as unknown as Record<string, NativeAttributeValue>,
493
+ UpdateExpression: 'DELETE joinedChannels :chans',
494
+ ExpressionAttributeValues: {
495
+ ':chans': chans as unknown as NativeAttributeValue,
496
+ },
497
+ },
498
+ });
499
+ }
500
+
501
+ await this.sendTransactWithRetry(transactItems);
502
+ }
503
+
504
+ /**
505
+ * Runs a `TransactWriteItems` with a bounded retry loop. Only
506
+ * `TransactionConflict` cancellations are retried (concurrent item
507
+ * access); condition/throughput failures rethrow immediately.
508
+ */
509
+ private async sendTransactWithRetry(items: TransactWriteItem[]): Promise<void> {
510
+ const MAX_ATTEMPTS = 6;
511
+ for (let attempt = 1; ; attempt++) {
512
+ try {
513
+ await this.dynamo.send(new TransactWriteCommand({ TransactItems: items }));
514
+ return;
515
+ } catch (err: unknown) {
516
+ if (isTransactionConflict(err) && attempt < MAX_ATTEMPTS) {
517
+ await sleep(RETRY_BACKOFF_BASE_MS * attempt);
518
+ continue;
519
+ }
520
+ throw err;
521
+ }
522
+ }
523
+ }
524
+
525
+ private async ensureMetaRow(displayName: ChanName, key: string): Promise<void> {
526
+ try {
527
+ await this.dynamo.send(
528
+ new PutCommand({
529
+ TableName: this.tables.ChannelMeta,
530
+ Item: {
531
+ channelName: key,
532
+ displayName,
533
+ modes: emptyModes(),
534
+ banMasks: [],
535
+ pendingInvites: [],
536
+ createdAt: this.clock.now(),
537
+ },
538
+ ConditionExpression: 'attribute_not_exists(channelName)',
539
+ }),
540
+ );
541
+ } catch (err: unknown) {
542
+ if (isConditionalCheckFailed(err)) return;
543
+ throw err;
544
+ }
545
+ }
546
+
547
+ private async readMeta(key: string): Promise<MarshalledChannelMeta | null> {
548
+ const result = await this.dynamo.send(
549
+ new GetCommand({ TableName: this.tables.ChannelMeta, Key: { channelName: key } }),
550
+ );
551
+ if (result.Item === undefined) return null;
552
+ return result.Item as unknown as MarshalledChannelMeta;
553
+ }
554
+
555
+ private async readMetaModes(key: string): Promise<ChannelModes | null> {
556
+ const meta = await this.readMeta(key);
557
+ return meta?.modes ?? null;
558
+ }
559
+
560
+ private async readBanMasks(key: string): Promise<string[]> {
561
+ const meta = await this.readMeta(key);
562
+ return meta?.banMasks ?? [];
563
+ }
564
+
565
+ private async updateMetaModes(key: string, modes: ChannelModes): Promise<void> {
566
+ await this.dynamo.send(
567
+ new UpdateCommand({
568
+ TableName: this.tables.ChannelMeta,
569
+ Key: { channelName: key },
570
+ UpdateExpression: 'SET modes = :m',
571
+ ExpressionAttributeValues: { ':m': modes },
572
+ }),
573
+ );
574
+ }
575
+
576
+ private async updateBanMasks(key: string, masks: Set<string>): Promise<void> {
577
+ await this.dynamo.send(
578
+ new UpdateCommand({
579
+ TableName: this.tables.ChannelMeta,
580
+ Key: { channelName: key },
581
+ UpdateExpression: 'SET banMasks = :b',
582
+ ExpressionAttributeValues: { ':b': [...masks] },
583
+ }),
584
+ );
585
+ }
586
+
587
+ private async updateTopic(key: string, topic: ChannelTopic | null): Promise<void> {
588
+ if (topic === null) {
589
+ await this.dynamo.send(
590
+ new UpdateCommand({
591
+ TableName: this.tables.ChannelMeta,
592
+ Key: { channelName: key },
593
+ UpdateExpression: 'REMOVE topicText, topicSetter, topicSetAt',
594
+ }),
595
+ );
596
+ return;
597
+ }
598
+ await this.dynamo.send(
599
+ new UpdateCommand({
600
+ TableName: this.tables.ChannelMeta,
601
+ Key: { channelName: key },
602
+ UpdateExpression: 'SET topicText = :t, topicSetter = :s, topicSetAt = :a',
603
+ ExpressionAttributeValues: {
604
+ ':t': topic.text,
605
+ ':s': topic.setter,
606
+ ':a': topic.setAt,
607
+ },
608
+ }),
609
+ );
610
+ }
611
+
612
+ private async queryMembers(key: string): Promise<MarshalledChannelMember[]> {
613
+ const result = await this.dynamo.send(
614
+ new QueryCommand({
615
+ TableName: this.tables.ChannelMembers,
616
+ KeyConditionExpression: 'channelName = :k',
617
+ ExpressionAttributeValues: { ':k': key },
618
+ }),
619
+ );
620
+ return (result.Items ?? []) as unknown as MarshalledChannelMember[];
621
+ }
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // cleanupConnection — re-exported via index.ts and called from $disconnect
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /**
629
+ * Tears down a connection: deletes its Connections row, removes every
630
+ * ChannelMembers row owned by it, releases its nick (if any), and — when
631
+ * a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
632
+ * that shares a channel with the disconnecting connection.
633
+ *
634
+ * Idempotent: every step tolerates a missing row. Safe to call from
635
+ * `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
636
+ * from the sweeper Lambda.
637
+ *
638
+ * Recursion safety: the Connections row is deleted *before* the QUIT
639
+ * fanout so a recursive `cleanupConnection` triggered by a gone peer
640
+ * during fanout finds the row already gone and returns immediately —
641
+ * no unbounded recursion.
642
+ *
643
+ * `managementApi` is optional because callers that never need fanout
644
+ * (the sweeper Lambda, or a no-op test) may pass `null`.
645
+ */
646
+ export async function cleanupConnection(
647
+ dynamo: DynamoDBDocumentClient,
648
+ tables: TablesConfig,
649
+ connId: ConnId,
650
+ managementApi: ApiGatewayManagementApi | PostToConnection | null,
651
+ quitMessage = 'Client Quit',
652
+ ): Promise<void> {
653
+ const connRow = await dynamo.send(
654
+ new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
655
+ );
656
+ const item = connRow.Item as unknown as MarshalledConnection | undefined;
657
+ if (item === undefined) return; // idempotent — already cleaned up
658
+
659
+ // Delete the Connections row FIRST. A recursive cleanup triggered by
660
+ // a gone peer during QUIT fanout reads this same row, finds it gone,
661
+ // and returns — bounding the recursion at one extra hop.
662
+ await dynamo.send(
663
+ new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
664
+ );
665
+
666
+ if (item.nick !== undefined) {
667
+ try {
668
+ await dynamo.send(
669
+ new DeleteCommand({
670
+ TableName: tables.Nicks,
671
+ Key: { nickLower: item.nick.toLowerCase() },
672
+ }),
673
+ );
674
+ } catch {
675
+ // Idempotent — the nick may have been released already.
676
+ }
677
+ }
678
+
679
+ // Broadcast QUIT to peers in shared channels *before* removing the
680
+ // disconnecting connection's membership rows — the roster query needs
681
+ // those rows to resolve the fanout targets.
682
+ if (item.nick !== undefined && managementApi !== null) {
683
+ await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
684
+ }
685
+
686
+ const joined = item.joinedChannels ?? [];
687
+ for (const chan of joined) {
688
+ try {
689
+ await dynamo.send(
690
+ new DeleteCommand({
691
+ TableName: tables.ChannelMembers,
692
+ Key: { channelName: chan.toLowerCase(), connectionId: connId },
693
+ }),
694
+ );
695
+ } catch {
696
+ // Idempotent — the membership may have been removed already.
697
+ }
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Builds the canonical `:nick!user@host QUIT :<reason>` line for the
703
+ * disconnecting connection and fans it out to every *other* member of
704
+ * each channel the connection had joined. A peer whose APIGW socket is
705
+ * already gone raises `GoneException`, which is caught and routed back
706
+ * through {@link cleanupConnection} for lazy cleanup — a partially
707
+ * torn-down roster never crashes the fanout loop.
708
+ *
709
+ * Shared by both the `$disconnect` route and the `send()`/`broadcast()`
710
+ * `GoneException` cleanup path.
711
+ */
712
+ async function broadcastQuitToChannels(
713
+ dynamo: DynamoDBDocumentClient,
714
+ tables: TablesConfig,
715
+ connId: ConnId,
716
+ item: MarshalledConnection,
717
+ managementApi: ApiGatewayManagementApi | PostToConnection,
718
+ quitMessage: string,
719
+ ): Promise<void> {
720
+ const hostmask = buildHostmask(item);
721
+ if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
722
+ const line = `:${hostmask} QUIT :${quitMessage}`;
723
+ const joined = item.joinedChannels ?? [];
724
+ for (const chan of joined) {
725
+ const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
726
+ for (const member of members) {
727
+ if (member.connectionId === connId) continue; // never echo to self
728
+ try {
729
+ await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
730
+ } catch (err: unknown) {
731
+ if (err instanceof GoneException || isGone(err)) {
732
+ await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
733
+ } else {
734
+ throw err;
735
+ }
736
+ }
737
+ }
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Builds the `nick!user@host` hostmask from a stored connection row,
743
+ * omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
744
+ * operates on the marshalled shape (no full unmarshal needed).
745
+ */
746
+ function buildHostmask(item: MarshalledConnection): string | undefined {
747
+ if (item.nick === undefined) return undefined;
748
+ let out: string = item.nick;
749
+ if (item.user !== undefined) out = `${out}!${item.user}`;
750
+ if (item.host !== undefined) out = `${out}@${item.host}`;
751
+ return out;
752
+ }
753
+
754
+ /** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
755
+ async function queryChannelMembersRows(
756
+ dynamo: DynamoDBDocumentClient,
757
+ tables: TablesConfig,
758
+ key: string,
759
+ ): Promise<MarshalledChannelMember[]> {
760
+ const result = await dynamo.send(
761
+ new QueryCommand({
762
+ TableName: tables.ChannelMembers,
763
+ KeyConditionExpression: 'channelName = :k',
764
+ ExpressionAttributeValues: { ':k': key },
765
+ }),
766
+ );
767
+ return (result.Items ?? []) as unknown as MarshalledChannelMember[];
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Helpers
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /** Base backoff (ms) for `TransactWriteItems` conflict retries — linear. */
775
+ const RETRY_BACKOFF_BASE_MS = 5;
776
+
777
+ /** Adds `chan` to the per-connection channel set, creating it if missing. */
778
+ function collectChannel(map: Map<ConnId, Set<string>>, conn: ConnId, chan: string): void {
779
+ const set = map.get(conn) ?? new Set<string>();
780
+ set.add(chan);
781
+ map.set(conn, set);
782
+ }
783
+
784
+ /** Resolves after `ms` milliseconds — used by the transact retry loop. */
785
+ function sleep(ms: number): Promise<void> {
786
+ return new Promise((resolve) => setTimeout(resolve, ms));
787
+ }
788
+
789
+ /**
790
+ * True iff the SDK error is a `TransactionCanceledException` whose
791
+ * cancellation reasons include a `TransactionConflict` (concurrent
792
+ * item access). Condition-check and validation failures return false
793
+ * so the caller rethrows instead of retrying pointlessly.
794
+ */
795
+ function isTransactionConflict(err: unknown): boolean {
796
+ if (err === null || typeof err !== 'object') return false;
797
+ const e = err as { name?: string; CancellationReasons?: Array<{ Code?: string }> };
798
+ if (e.name !== 'TransactionCanceledException') return false;
799
+ const reasons = e.CancellationReasons ?? [];
800
+ return reasons.some((r) => r?.Code === 'TransactionConflict');
801
+ }
802
+
803
+ /** Applies a sequence of `ModeChange` to a `ChannelModes` (mutates in place). */
804
+ function applyModeChanges(modes: ChannelModes, changes: ChannelDelta['modeChanges']): ChannelModes {
805
+ if (changes === undefined) return modes;
806
+ for (const change of changes) {
807
+ switch (change.mode) {
808
+ case 'inviteOnly':
809
+ case 'topicLock':
810
+ case 'noExternal':
811
+ case 'moderated':
812
+ case 'secret':
813
+ case 'private':
814
+ modes[change.mode] = change.set;
815
+ break;
816
+ case 'key':
817
+ if (change.set) {
818
+ if (typeof change.arg === 'string') modes.key = change.arg;
819
+ } else {
820
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
821
+ delete modes.key;
822
+ }
823
+ break;
824
+ case 'limit':
825
+ if (change.set) {
826
+ if (typeof change.arg === 'number') modes.limit = change.arg;
827
+ } else {
828
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
829
+ delete modes.limit;
830
+ }
831
+ break;
832
+ }
833
+ }
834
+ return modes;
835
+ }
836
+
837
+ /** ChannelState → ChanSnapshot (mirrors irc-core's `toSnapshot`). */
838
+ function chanStateToSnapshot(chan: ChannelState): ChanSnapshot {
839
+ const out: ChanSnapshot = {
840
+ name: chan.name,
841
+ nameLower: chan.nameLower,
842
+ modes: chan.modes,
843
+ banMasks: chan.banMasks,
844
+ members: chan.members,
845
+ pendingInvites: chan.pendingInvites,
846
+ createdAt: chan.createdAt,
847
+ ...(chan.topic !== undefined ? { topic: chan.topic } : {}),
848
+ };
849
+ return out;
850
+ }
851
+
852
+ /** True iff the SDK error is a `ConditionalCheckFailedException`. */
853
+ function isConditionalCheckFailed(err: unknown): boolean {
854
+ if (err === null || typeof err !== 'object') return false;
855
+ const name = (err as { name?: string }).name;
856
+ return name === 'ConditionalCheckFailedException';
857
+ }
858
+
859
+ /** True iff the SDK error is a `ResourceNotFoundException`. */
860
+ function isResourceNotFound(err: unknown): boolean {
861
+ if (err === null || typeof err !== 'object') return false;
862
+ const name = (err as { name?: string }).name;
863
+ return name === 'ResourceNotFoundException';
864
+ }
865
+
866
+ /** True iff the SDK error indicates the APIGW connection is gone. */
867
+ function isGone(err: unknown): boolean {
868
+ if (err === null || typeof err !== 'object') return false;
869
+ const name = (err as { name?: string }).name;
870
+ return name === 'GoneException' || name === 'ForbiddenException';
871
+ }