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,530 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InMemoryMessageStore, type MessageStore, type StoredMessage } from '../src/ports';
3
+
4
+ /** Builds a stored message with sensible defaults for tests. */
5
+ function msg(
6
+ chan: string,
7
+ msgid: string,
8
+ text: string,
9
+ time: number,
10
+ command: StoredMessage['command'] = 'PRIVMSG',
11
+ ): StoredMessage {
12
+ return {
13
+ msgid,
14
+ time,
15
+ chan: chan.toLowerCase(),
16
+ command,
17
+ nick: 'alice',
18
+ user: 'alice',
19
+ host: 'example.com',
20
+ text,
21
+ };
22
+ }
23
+
24
+ /** Records 5 chronological messages into the store and returns it. */
25
+ function seededStore(maxPerChannel = 100): InMemoryMessageStore {
26
+ const store = new InMemoryMessageStore(maxPerChannel);
27
+ store.record(msg('#foo', 'm1', 'one', 1_000));
28
+ store.record(msg('#foo', 'm2', 'two', 2_000));
29
+ store.record(msg('#foo', 'm3', 'three', 3_000));
30
+ store.record(msg('#foo', 'm4', 'four', 4_000));
31
+ store.record(msg('#foo', 'm5', 'five', 5_000));
32
+ return store;
33
+ }
34
+
35
+ const ids = (ms: StoredMessage[]): string[] => ms.map((m) => m.msgid);
36
+
37
+ describe('InMemoryMessageStore — record + LATEST', () => {
38
+ it('implements the MessageStore port', () => {
39
+ const store: MessageStore = new InMemoryMessageStore();
40
+ expect(store).toBeDefined();
41
+ });
42
+
43
+ it('returns recorded messages chronologically (oldest first) for LATEST *', () => {
44
+ const store = seededStore();
45
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 50 });
46
+ expect(ids(out)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']);
47
+ });
48
+
49
+ it('returns at most `limit` most-recent messages, oldest-first', () => {
50
+ const store = seededStore();
51
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 2 });
52
+ expect(ids(out)).toEqual(['m4', 'm5']);
53
+ });
54
+
55
+ it('returns fewer than the limit when history is shorter (no error)', () => {
56
+ const store = seededStore();
57
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 50 });
58
+ expect(out).toHaveLength(5);
59
+ });
60
+
61
+ it('returns an empty array for a channel with no history', () => {
62
+ const store = new InMemoryMessageStore();
63
+ const out = store.query({ chan: '#empty', direction: 'latest', limit: 50 });
64
+ expect(out).toEqual([]);
65
+ });
66
+
67
+ it('returns an empty array when the limit is zero', () => {
68
+ const store = seededStore();
69
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 0 });
70
+ expect(out).toEqual([]);
71
+ });
72
+
73
+ it('matches channel names case-insensitively', () => {
74
+ const store = seededStore();
75
+ const out = store.query({ chan: '#FOO', direction: 'latest', limit: 1 });
76
+ expect(ids(out)).toEqual(['m5']);
77
+ });
78
+
79
+ it('LATEST with a msgid pivot returns up to that msgid inclusive, newest-first bounded', () => {
80
+ const store = seededStore();
81
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 50, msgid: 'm3' });
82
+ expect(ids(out)).toEqual(['m1', 'm2', 'm3']);
83
+ });
84
+ });
85
+
86
+ describe('InMemoryMessageStore — ring buffer eviction', () => {
87
+ it('drops the oldest message once the per-channel cap is exceeded', () => {
88
+ const store = new InMemoryMessageStore(3);
89
+ store.record(msg('#foo', 'm1', 'one', 1_000));
90
+ store.record(msg('#foo', 'm2', 'two', 2_000));
91
+ store.record(msg('#foo', 'm3', 'three', 3_000));
92
+ store.record(msg('#foo', 'm4', 'four', 4_000));
93
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 50 });
94
+ expect(ids(out)).toEqual(['m2', 'm3', 'm4']);
95
+ });
96
+
97
+ it('keeps every message when count is below the cap', () => {
98
+ const store = new InMemoryMessageStore(100);
99
+ store.record(msg('#foo', 'm1', 'one', 1_000));
100
+ store.record(msg('#foo', 'm2', 'two', 2_000));
101
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 50 });
102
+ expect(out).toHaveLength(2);
103
+ });
104
+
105
+ it('defaults the per-channel cap to 100 when omitted', () => {
106
+ const store = new InMemoryMessageStore();
107
+ for (let i = 0; i < 120; i++) {
108
+ store.record(msg('#foo', `m${i}`, `t${i}`, 1_000 + i));
109
+ }
110
+ const out = store.query({ chan: '#foo', direction: 'latest', limit: 200 });
111
+ // Oldest 20 evicted; the most recent 100 remain.
112
+ expect(out).toHaveLength(100);
113
+ expect(out[0]?.msgid).toBe('m20');
114
+ expect(out[99]?.msgid).toBe('m119');
115
+ });
116
+
117
+ it('evicts per channel independently (other channels are unaffected)', () => {
118
+ const store = new InMemoryMessageStore(2);
119
+ store.record(msg('#foo', 'f1', 'one', 1_000));
120
+ store.record(msg('#bar', 'b1', 'one', 1_000));
121
+ store.record(msg('#foo', 'f2', 'two', 2_000));
122
+ store.record(msg('#foo', 'f3', 'three', 3_000)); // evicts f1
123
+ expect(ids(store.query({ chan: '#foo', direction: 'latest', limit: 10 }))).toEqual([
124
+ 'f2',
125
+ 'f3',
126
+ ]);
127
+ expect(ids(store.query({ chan: '#bar', direction: 'latest', limit: 10 }))).toEqual(['b1']);
128
+ });
129
+ });
130
+
131
+ describe('InMemoryMessageStore — BEFORE / AFTER / AROUND / BETWEEN', () => {
132
+ it('BEFORE returns the N messages preceding the pivot, oldest-first', () => {
133
+ const store = seededStore();
134
+ const out = store.query({ chan: '#foo', direction: 'before', limit: 2, msgid: 'm4' });
135
+ expect(ids(out)).toEqual(['m2', 'm3']);
136
+ });
137
+
138
+ it('BEFORE at the start of history returns fewer than the limit with no error', () => {
139
+ const store = seededStore();
140
+ const out = store.query({ chan: '#foo', direction: 'before', limit: 10, msgid: 'm1' });
141
+ expect(out).toEqual([]);
142
+ });
143
+
144
+ it('BEFORE with a limit larger than available returns everything before the pivot', () => {
145
+ const store = seededStore();
146
+ const out = store.query({ chan: '#foo', direction: 'before', limit: 50, msgid: 'm3' });
147
+ expect(ids(out)).toEqual(['m1', 'm2']);
148
+ });
149
+
150
+ it('AFTER returns the N messages following the pivot, oldest-first', () => {
151
+ const store = seededStore();
152
+ const out = store.query({ chan: '#foo', direction: 'after', limit: 2, msgid: 'm2' });
153
+ expect(ids(out)).toEqual(['m3', 'm4']);
154
+ });
155
+
156
+ it('AFTER at the end of history returns an empty slice', () => {
157
+ const store = seededStore();
158
+ const out = store.query({ chan: '#foo', direction: 'after', limit: 10, msgid: 'm5' });
159
+ expect(out).toEqual([]);
160
+ });
161
+
162
+ it('AROUND returns messages spanning the pivot (half before, the pivot, half after)', () => {
163
+ const store = seededStore();
164
+ // limit 3 → one before, the pivot, one after
165
+ const out = store.query({ chan: '#foo', direction: 'around', limit: 3, msgid: 'm3' });
166
+ expect(ids(out)).toEqual(['m2', 'm3', 'm4']);
167
+ });
168
+
169
+ it('AROUND at the start of history returns the first N including the pivot', () => {
170
+ const store = seededStore();
171
+ const out = store.query({ chan: '#foo', direction: 'around', limit: 3, msgid: 'm1' });
172
+ expect(ids(out)).toEqual(['m1', 'm2', 'm3']);
173
+ });
174
+
175
+ it('BETWEEN returns messages after m1 up to and including m2, oldest-first', () => {
176
+ const store = seededStore();
177
+ const out = store.query({
178
+ chan: '#foo',
179
+ direction: 'between',
180
+ limit: 50,
181
+ msgid: 'm2',
182
+ msgid2: 'm4',
183
+ });
184
+ expect(ids(out)).toEqual(['m3', 'm4']);
185
+ });
186
+
187
+ it('BETWEEN with bounds at the extremes returns the inner slice', () => {
188
+ const store = seededStore();
189
+ const out = store.query({
190
+ chan: '#foo',
191
+ direction: 'between',
192
+ limit: 50,
193
+ msgid: 'm1',
194
+ msgid2: 'm5',
195
+ });
196
+ expect(ids(out)).toEqual(['m2', 'm3', 'm4', 'm5']);
197
+ });
198
+ });
199
+
200
+ describe('InMemoryMessageStore — hasMsgid', () => {
201
+ it('returns true for a recorded msgid in the channel', () => {
202
+ const store = seededStore();
203
+ expect(store.hasMsgid('#foo', 'm3')).toBe(true);
204
+ });
205
+
206
+ it('returns false for an unknown msgid', () => {
207
+ const store = seededStore();
208
+ expect(store.hasMsgid('#foo', 'nope')).toBe(false);
209
+ });
210
+
211
+ it('returns false when the msgid exists in another channel', () => {
212
+ const store = new InMemoryMessageStore();
213
+ store.record(msg('#foo', 'm1', 'one', 1_000));
214
+ expect(store.hasMsgid('#bar', 'm1')).toBe(false);
215
+ });
216
+
217
+ it('returns false for an evicted (oldest) msgid', () => {
218
+ const store = new InMemoryMessageStore(2);
219
+ store.record(msg('#foo', 'm1', 'one', 1_000));
220
+ store.record(msg('#foo', 'm2', 'two', 2_000));
221
+ store.record(msg('#foo', 'm3', 'three', 3_000)); // evicts m1
222
+ expect(store.hasMsgid('#foo', 'm1')).toBe(false);
223
+ expect(store.hasMsgid('#foo', 'm2')).toBe(true);
224
+ });
225
+
226
+ it('matches channel name case-insensitively', () => {
227
+ const store = seededStore();
228
+ expect(store.hasMsgid('#FOO', 'm3')).toBe(true);
229
+ });
230
+ });
231
+
232
+ describe('InMemoryMessageStore — targets', () => {
233
+ it('enumerates channels with activity inside the window, newest first', () => {
234
+ const store = new InMemoryMessageStore();
235
+ store.record(msg('#foo', 'f1', 'one', 1_000));
236
+ store.record(msg('#bar', 'b1', 'one', 5_000));
237
+ store.record(msg('#foo', 'f2', 'two', 3_000));
238
+ const out = store.targets(0, 10_000);
239
+ expect(out).toEqual([
240
+ { chan: '#bar', time: 5_000 },
241
+ { chan: '#foo', time: 3_000 },
242
+ ]);
243
+ });
244
+
245
+ it('excludes channels whose only activity is outside the window', () => {
246
+ const store = new InMemoryMessageStore();
247
+ store.record(msg('#foo', 'f1', 'one', 1_000));
248
+ store.record(msg('#bar', 'b1', 'one', 10_000));
249
+ const out = store.targets(2_000, 5_000);
250
+ expect(out).toEqual([]);
251
+ });
252
+
253
+ it('uses the most recent in-window time per channel', () => {
254
+ const store = new InMemoryMessageStore();
255
+ store.record(msg('#foo', 'f1', 'one', 1_000));
256
+ store.record(msg('#foo', 'f2', 'two', 4_000));
257
+ store.record(msg('#foo', 'f3', 'three', 9_000)); // outside window
258
+ const out = store.targets(0, 5_000);
259
+ expect(out).toEqual([{ chan: '#foo', time: 4_000 }]);
260
+ });
261
+
262
+ it('returns an empty array when the store has no messages at all', () => {
263
+ const store = new InMemoryMessageStore();
264
+ expect(store.targets(0, 10_000)).toEqual([]);
265
+ });
266
+ });
267
+
268
+ describe('InMemoryMessageStore — query guards', () => {
269
+ it('returns an empty array for a BEFORE/AFTER pivot msgid that does not exist', () => {
270
+ const store = seededStore();
271
+ expect(store.query({ chan: '#foo', direction: 'before', limit: 5, msgid: 'missing' })).toEqual(
272
+ [],
273
+ );
274
+ expect(store.query({ chan: '#foo', direction: 'after', limit: 5, msgid: 'missing' })).toEqual(
275
+ [],
276
+ );
277
+ });
278
+ });
279
+
280
+ describe('InMemoryMessageStore — recent (JOIN playback query)', () => {
281
+ it('returns the most recent `limit` overall when no marker is supplied', () => {
282
+ const store = seededStore();
283
+ const out = store.recent('#foo', undefined, 2);
284
+ expect(ids(out)).toEqual(['m4', 'm5']);
285
+ });
286
+
287
+ it('returns the most recent messages strictly after the marker, oldest-first', () => {
288
+ const store = seededStore(); // m1..m5
289
+ const out = store.recent('#foo', 'm2', 50);
290
+ expect(ids(out)).toEqual(['m3', 'm4', 'm5']);
291
+ });
292
+
293
+ it('caps the after-marker result at `limit` (most recent wins)', () => {
294
+ const store = seededStore(); // m1..m5
295
+ // After m1 there are m2..m5 (4); limit 2 → the most recent two: m4, m5.
296
+ const out = store.recent('#foo', 'm1', 2);
297
+ expect(ids(out)).toEqual(['m4', 'm5']);
298
+ });
299
+
300
+ it('returns nothing when the marker is the most recent message (no new activity)', () => {
301
+ const store = seededStore();
302
+ expect(store.recent('#foo', 'm5', 50)).toEqual([]);
303
+ });
304
+
305
+ it('falls back to most-recent-overall when the marker was evicted', () => {
306
+ const store = new InMemoryMessageStore(3); // keeps m3..m5 after seeding m1..m5
307
+ store.record(msg('#foo', 'm1', 'one', 1_000));
308
+ store.record(msg('#foo', 'm2', 'two', 2_000));
309
+ store.record(msg('#foo', 'm3', 'three', 3_000));
310
+ store.record(msg('#foo', 'm4', 'four', 4_000));
311
+ store.record(msg('#foo', 'm5', 'five', 5_000));
312
+ // m1 evicted; marker m1 unknown → replay most recent `limit`.
313
+ const out = store.recent('#foo', 'm1', 2);
314
+ expect(ids(out)).toEqual(['m4', 'm5']);
315
+ });
316
+
317
+ it('returns an empty array for a channel with no history', () => {
318
+ const store = new InMemoryMessageStore();
319
+ expect(store.recent('#empty', undefined, 50)).toEqual([]);
320
+ });
321
+
322
+ it('returns an empty array when limit is zero', () => {
323
+ const store = seededStore();
324
+ expect(store.recent('#foo', undefined, 0)).toEqual([]);
325
+ });
326
+
327
+ it('matches channel name case-insensitively', () => {
328
+ const store = seededStore();
329
+ expect(ids(store.recent('#FOO', undefined, 1))).toEqual(['m5']);
330
+ });
331
+ });
332
+
333
+ // ============================================================================
334
+ // query() defensive default branch
335
+ // ============================================================================
336
+
337
+ describe('InMemoryMessageStore — query default branch', () => {
338
+ it('returns an empty array for an unrecognized direction (defensive)', () => {
339
+ const store = seededStore();
340
+ // The HistoryDirection union exhaustively matches the switch; cast through
341
+ // unknown to exercise the unreachable default branch so it stays a safety
342
+ // net rather than dead code from coverage's point of view.
343
+ const result = store.query({
344
+ chan: '#foo',
345
+ direction: 'bogus' as unknown as 'latest',
346
+ limit: 5,
347
+ });
348
+ expect(result).toEqual([]);
349
+ });
350
+
351
+ it('returns an empty array when query limit is zero (tail early return)', () => {
352
+ const store = seededStore();
353
+ expect(store.query({ chan: '#foo', direction: 'latest', limit: 0 })).toEqual([]);
354
+ });
355
+ });
356
+
357
+ // ============================================================================
358
+ // query() edge cases (defensive branches)
359
+ // ============================================================================
360
+
361
+ describe('InMemoryMessageStore — query defensive branches', () => {
362
+ it('LATEST with an unknown msgid pivot returns an empty array', () => {
363
+ const store = seededStore();
364
+ expect(
365
+ ids(
366
+ store.query({
367
+ chan: '#foo',
368
+ direction: 'latest',
369
+ limit: 5,
370
+ msgid: 'ghost',
371
+ }),
372
+ ),
373
+ ).toEqual([]);
374
+ });
375
+
376
+ it('BEFORE with an undefined msgid returns an empty array', () => {
377
+ const store = seededStore();
378
+ expect(
379
+ store.query({
380
+ chan: '#foo',
381
+ direction: 'before',
382
+ limit: 5,
383
+ msgid: undefined as unknown as string,
384
+ }),
385
+ ).toEqual([]);
386
+ });
387
+
388
+ it('BEFORE with an unknown msgid returns an empty array', () => {
389
+ const store = seededStore();
390
+ expect(
391
+ ids(
392
+ store.query({
393
+ chan: '#foo',
394
+ direction: 'before',
395
+ limit: 5,
396
+ msgid: 'ghost',
397
+ }),
398
+ ),
399
+ ).toEqual([]);
400
+ });
401
+
402
+ it('AFTER with an undefined msgid returns an empty array', () => {
403
+ const store = seededStore();
404
+ expect(
405
+ store.query({
406
+ chan: '#foo',
407
+ direction: 'after',
408
+ limit: 5,
409
+ msgid: undefined as unknown as string,
410
+ }),
411
+ ).toEqual([]);
412
+ });
413
+
414
+ it('AFTER with an unknown msgid returns an empty array', () => {
415
+ const store = seededStore();
416
+ expect(
417
+ ids(
418
+ store.query({
419
+ chan: '#foo',
420
+ direction: 'after',
421
+ limit: 5,
422
+ msgid: 'ghost',
423
+ }),
424
+ ),
425
+ ).toEqual([]);
426
+ });
427
+
428
+ it('AROUND with an undefined msgid returns an empty array', () => {
429
+ const store = seededStore();
430
+ expect(
431
+ store.query({
432
+ chan: '#foo',
433
+ direction: 'around',
434
+ limit: 5,
435
+ msgid: undefined as unknown as string,
436
+ }),
437
+ ).toEqual([]);
438
+ });
439
+
440
+ it('AROUND with an unknown msgid returns an empty array', () => {
441
+ const store = seededStore();
442
+ expect(
443
+ ids(
444
+ store.query({
445
+ chan: '#foo',
446
+ direction: 'around',
447
+ limit: 5,
448
+ msgid: 'ghost',
449
+ }),
450
+ ),
451
+ ).toEqual([]);
452
+ });
453
+
454
+ it('AROUND slides the window right when the pivot is near the start', () => {
455
+ const store = seededStore();
456
+ // Pivot at m1, limit 4. Naive centering would start at -1; the slide
457
+ // pins it to 0. Covers `start + limit > arr.length` is false here, but
458
+ // the `start < 0 → start = 0` branch fires.
459
+ expect(
460
+ ids(
461
+ store.query({
462
+ chan: '#foo',
463
+ direction: 'around',
464
+ limit: 4,
465
+ msgid: 'm1',
466
+ }),
467
+ ),
468
+ ).toEqual(['m1', 'm2', 'm3', 'm4']);
469
+ });
470
+
471
+ it('AROUND slides the window left and clamps when limit exceeds the array', () => {
472
+ // Seed a 2-message channel; AROUND with limit 5 forces both slide rules.
473
+ const store = new InMemoryMessageStore();
474
+ store.record(msg('#tiny', 'a1', 'one', 1_000));
475
+ store.record(msg('#tiny', 'a2', 'two', 2_000));
476
+ expect(
477
+ ids(
478
+ store.query({
479
+ chan: '#tiny',
480
+ direction: 'around',
481
+ limit: 5,
482
+ msgid: 'a1',
483
+ }),
484
+ ),
485
+ ).toEqual(['a1', 'a2']);
486
+ });
487
+
488
+ it('BETWEEN with undefined lo returns an empty array', () => {
489
+ const store = seededStore();
490
+ expect(
491
+ store.query({
492
+ chan: '#foo',
493
+ direction: 'between',
494
+ limit: 5,
495
+ msgid: undefined as unknown as string,
496
+ msgid2: 'm3',
497
+ }),
498
+ ).toEqual([]);
499
+ });
500
+
501
+ it('BETWEEN with an unknown bound returns an empty array', () => {
502
+ const store = seededStore();
503
+ expect(
504
+ ids(
505
+ store.query({
506
+ chan: '#foo',
507
+ direction: 'between',
508
+ limit: 5,
509
+ msgid: 'ghost',
510
+ msgid2: 'm3',
511
+ }),
512
+ ),
513
+ ).toEqual([]);
514
+ });
515
+
516
+ it('BETWEEN with lo >= hi returns an empty array', () => {
517
+ const store = seededStore();
518
+ expect(
519
+ ids(
520
+ store.query({
521
+ chan: '#foo',
522
+ direction: 'between',
523
+ limit: 5,
524
+ msgid: 'm3',
525
+ msgid2: 'm1',
526
+ }),
527
+ ),
528
+ ).toEqual([]);
529
+ });
530
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { IrcMessage } from '../src/protocol/messages';
3
- import { IrcParseError, parse } from '../src/protocol/parser';
3
+ import { IrcParseError, MAX_INPUT_BYTES, parse } from '../src/protocol/parser';
4
4
 
5
5
  describe('parse — golden vectors', () => {
6
6
  it('parses a simple command with a channel and trailing text', () => {
@@ -160,3 +160,46 @@ describe('parse — malformed input', () => {
160
160
  expect(() => parse('12 hi')).toThrow(IrcParseError);
161
161
  });
162
162
  });
163
+
164
+ describe('parse — RFC 1459 §2.3 input line-length cap', () => {
165
+ it('accepts a line whose body is exactly 512 bytes (post-CRLF-strip)', () => {
166
+ // 512 = "PRIVMSG #foo :" (14) + 498 'x' chars = 512-byte body.
167
+ const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length);
168
+ const line = `PRIVMSG #foo :${body}`;
169
+ expect(line.length).toBe(MAX_INPUT_BYTES);
170
+ const parsed = parse(line);
171
+ expect(parsed.params).toEqual(['#foo', body]);
172
+ });
173
+
174
+ it('accepts a line whose body is exactly 512 bytes including a trailing CR-LF', () => {
175
+ const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length);
176
+ const parsed = parse(`PRIVMSG #foo :${body}\r\n`);
177
+ expect(parsed.params[1]).toBe(body);
178
+ });
179
+
180
+ it('throws IrcParseError on a line whose body exceeds 512 bytes (post-strip)', () => {
181
+ const body = 'x'.repeat(MAX_INPUT_BYTES - 'PRIVMSG #foo :'.length + 1);
182
+ const line = `PRIVMSG #foo :${body}`;
183
+ expect(line.length).toBe(MAX_INPUT_BYTES + 1);
184
+ expect(() => parse(line)).toThrow(IrcParseError);
185
+ });
186
+
187
+ it('throws IrcParseError on an over-long line even with tags stripped', () => {
188
+ const tag = '@time=2024-01-01T00:00:00.000Z ';
189
+ const body = 'x'.repeat(MAX_INPUT_BYTES - tag.length - 'PRIVMSG #foo :'.length + 1);
190
+ const line = `${tag}PRIVMSG #foo :${body}`;
191
+ expect(line.length).toBeGreaterThan(MAX_INPUT_BYTES);
192
+ expect(() => parse(line)).toThrow(IrcParseError);
193
+ });
194
+
195
+ it('reports the actual byte cap in the error message for diagnostics', () => {
196
+ const body = 'x'.repeat(MAX_INPUT_BYTES);
197
+ try {
198
+ parse(`PRIVMSG #foo :${body}`);
199
+ expect.fail('expected parse to throw');
200
+ } catch (err) {
201
+ expect(err).toBeInstanceOf(IrcParseError);
202
+ expect((err as Error).message).toMatch(/512/u);
203
+ }
204
+ });
205
+ });