socketduct 0.1.25 → 0.1.26

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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,8 +1,7 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it} from "@velocious/testing"
2
2
  import {access, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"
3
3
  import {tmpdir} from "node:os"
4
4
  import {join} from "node:path"
5
- import test from "node:test"
6
5
 
7
6
  import {
8
7
  SessionSpool,
@@ -23,464 +22,481 @@ async function withTempDir(fn) {
23
22
  }
24
23
  }
25
24
 
26
- test("SessionSpool creates private per-session temp storage with metadata", async () => {
27
- await withTempDir(async (root) => {
28
- const spool = new SessionSpool({rootDir: root})
29
- const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
30
- const mode = (await stat(session.directory)).mode & 0o777
25
+ describe("session spool", () => {
26
+ it("SessionSpool creates private per-session temp storage with metadata", async () => {
27
+ await withTempDir(async (root) => {
28
+ const spool = new SessionSpool({rootDir: root})
29
+ const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
30
+ const mode = (await stat(session.directory)).mode & 0o777
31
31
 
32
- assert.equal(mode, 0o700)
33
- assert.equal(session.metadata.sessionId, "session_1234567890")
34
- assert.equal(session.metadata.ownerId, "user-1")
32
+ expect(mode).toBe(0o700)
33
+ expect(session.metadata.sessionId).toBe("session_1234567890")
34
+ expect(session.metadata.ownerId).toBe("user-1")
35
+ })
35
36
  })
36
- })
37
-
38
- test("SessionSpool writes chunks and reads only chunks after a requested sequence", async () => {
39
- await withTempDir(async (root) => {
40
- const spool = new SessionSpool({rootDir: root})
41
- await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
42
- await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
43
- await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from([0, 255])})
44
- await spool.appendChunk({sessionId: "session_1234567890", sequence: 3, data: Buffer.from("three")})
45
37
 
46
- const chunks = await spool.readChunksAfter("session_1234567890", 1)
47
-
48
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [2, 3])
49
- assert.deepEqual(chunks[0]?.data, Buffer.from([0, 255]))
50
- assert.equal(chunks[1]?.data.toString("utf8"), "three")
38
+ it("SessionSpool writes chunks and reads only chunks after a requested sequence", async () => {
39
+ await withTempDir(async (root) => {
40
+ const spool = new SessionSpool({rootDir: root})
41
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
42
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
43
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from([0, 255])})
44
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 3, data: Buffer.from("three")})
45
+
46
+ const chunks = await spool.readChunksAfter("session_1234567890", 1)
47
+
48
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([2, 3])
49
+ const firstData = chunks[0]?.data
50
+ if (firstData === undefined) throw new Error("Expected a chunk after sequence 1")
51
+ expect(firstData.equals(Buffer.from([0, 255]))).toBe(true)
52
+ expect(chunks[1]?.data.toString("utf8")).toBe("three")
53
+ })
51
54
  })
52
- })
53
55
 
54
- test("SessionSpool truncates acknowledged chunks", async () => {
55
- await withTempDir(async (root) => {
56
- const spool = new SessionSpool({rootDir: root})
57
- await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
58
- await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
59
- await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from("two")})
60
- await spool.truncateAcked("session_1234567890", 1)
56
+ it("SessionSpool truncates acknowledged chunks", async () => {
57
+ await withTempDir(async (root) => {
58
+ const spool = new SessionSpool({rootDir: root})
59
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
60
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
61
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from("two")})
62
+ await spool.truncateAcked("session_1234567890", 1)
61
63
 
62
- assert.deepEqual((await spool.readChunksAfter("session_1234567890", 0)).map((chunk) => chunk.sequence), [2])
64
+ expect((await spool.readChunksAfter("session_1234567890", 0)).map((chunk) => chunk.sequence)).toEqual([2])
65
+ })
63
66
  })
64
- })
65
-
66
- test("SessionSpool truncates an acknowledgement inside a chunk in application bytes", async () => {
67
- await withTempDir(async (root) => {
68
- const spool = new SessionSpool({rootDir: root})
69
- const sessionId = "session_partial_ack"
70
- await spool.create({sessionId, ownerId: "user-1"})
71
- await spool.appendChunk({sessionId, sequence: 8, data: Buffer.from("abcdefgh")})
72
67
 
73
- await spool.truncateAcked(sessionId, 3)
74
-
75
- const chunks = await spool.readChunksAfter(sessionId, 3)
76
- assert.deepEqual(chunks, [{sequence: 8, data: Buffer.from("defgh")}])
77
- assert.equal(spool.getUsedBytes(sessionId), 5)
68
+ it("SessionSpool truncates an acknowledgement inside a chunk in application bytes", async () => {
69
+ await withTempDir(async (root) => {
70
+ const spool = new SessionSpool({rootDir: root})
71
+ const sessionId = "session_partial_ack"
72
+ await spool.create({sessionId, ownerId: "user-1"})
73
+ await spool.appendChunk({sessionId, sequence: 8, data: Buffer.from("abcdefgh")})
74
+
75
+ await spool.truncateAcked(sessionId, 3)
76
+
77
+ const chunks = await spool.readChunksAfter(sessionId, 3)
78
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([8])
79
+ const data = chunks[0]?.data
80
+ if (data === undefined) throw new Error("Expected a partially truncated chunk")
81
+ expect(data.equals(Buffer.from("defgh"))).toBe(true)
82
+ expect(spool.getUsedBytes(sessionId)).toBe(5)
83
+ })
78
84
  })
79
- })
80
85
 
81
- test("SessionSpool atomically admits a complete chunk batch before recovery commit", async () => {
82
- await withTempDir(async (root) => {
83
- const spool = new SessionSpool({rootDir: root, maxBytes: 32})
84
- const sessionId = "session_durable_batch"
85
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
86
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 4, data: Buffer.from("base")})
87
- await spool.writeRecoveryState(sessionId, {version: 3, sendSequence: 4})
88
-
89
- await spool.appendChunksAndRecoveryState({
90
- sessionId,
91
- ownerId: "reverse-client-v3",
92
- previousSequence: 4,
93
- chunks: [
94
- {sequence: 7, data: Buffer.from("one")},
95
- {sequence: 10, data: Buffer.from("two")}
96
- ],
97
- recoveryState: {version: 3, sendSequence: 10}
86
+ it("SessionSpool atomically admits a complete chunk batch before recovery commit", async () => {
87
+ await withTempDir(async (root) => {
88
+ const spool = new SessionSpool({rootDir: root, maxBytes: 32})
89
+ const sessionId = "session_durable_batch"
90
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
91
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 4, data: Buffer.from("base")})
92
+ await spool.writeRecoveryState(sessionId, {version: 3, sendSequence: 4})
93
+
94
+ await spool.appendChunksAndRecoveryState({
95
+ sessionId,
96
+ ownerId: "reverse-client-v3",
97
+ previousSequence: 4,
98
+ chunks: [
99
+ {sequence: 7, data: Buffer.from("one")},
100
+ {sequence: 10, data: Buffer.from("two")}
101
+ ],
102
+ recoveryState: {version: 3, sendSequence: 10}
103
+ })
104
+
105
+ expect((await spool.readChunksAfter(sessionId, 0)).map(({sequence, data}) => [sequence, data.toString()])).toEqual([
106
+ [4, "base"],
107
+ [7, "one"],
108
+ [10, "two"]
109
+ ])
110
+ expect(spool.readRecoveryStateSync(sessionId, "reverse-client-v3")).toEqual({version: 3, sendSequence: 10})
111
+ expect(spool.getUsedBytes(sessionId)).toBe(10)
98
112
  })
99
-
100
- assert.deepEqual((await spool.readChunksAfter(sessionId, 0)).map(({sequence, data}) => [sequence, data.toString()]), [
101
- [4, "base"],
102
- [7, "one"],
103
- [10, "two"]
104
- ])
105
- assert.deepEqual(spool.readRecoveryStateSync(sessionId, "reverse-client-v3"), {version: 3, sendSequence: 10})
106
- assert.equal(spool.getUsedBytes(sessionId), 10)
107
113
  })
108
- })
109
114
 
110
- test("SessionSpool recovery lookup distinguishes missing state from invalid state", async () => {
111
- await withTempDir(async (root) => {
112
- const spool = new SessionSpool({rootDir: root})
113
- assert.equal(spool.findRecoveryStateSync("missing_session", "edge-v3"), undefined)
115
+ it("SessionSpool recovery lookup distinguishes missing state from invalid state", async () => {
116
+ await withTempDir(async (root) => {
117
+ const spool = new SessionSpool({rootDir: root})
118
+ expect(spool.findRecoveryStateSync("missing_session", "edge-v3")).toBeUndefined()
114
119
 
115
- await spool.createNew({sessionId: "present_session", ownerId: "edge-v3"})
116
- await spool.writeRecoveryState("present_session", {version: 3})
117
- assert.deepEqual(spool.findRecoveryStateSync("present_session", "edge-v3"), {version: 3})
120
+ await spool.createNew({sessionId: "present_session", ownerId: "edge-v3"})
121
+ await spool.writeRecoveryState("present_session", {version: 3})
122
+ expect(spool.findRecoveryStateSync("present_session", "edge-v3")).toEqual({version: 3})
118
123
 
119
- await writeFile(join(root, "present_session", "recovery.json"), "{invalid\n")
120
- assert.throws(() => spool.findRecoveryStateSync("present_session", "edge-v3"), /Invalid Socketduct recovery state/)
124
+ await writeFile(join(root, "present_session", "recovery.json"), "{invalid\n")
125
+ expect(() => spool.findRecoveryStateSync("present_session", "edge-v3")).toThrow(/Invalid Socketduct recovery state/)
126
+ })
121
127
  })
122
- })
123
128
 
124
- test("SessionSpool rejects path traversal and mismatched metadata", async () => {
125
- await withTempDir(async (root) => {
126
- const spool = new SessionSpool({rootDir: root})
129
+ it("SessionSpool rejects path traversal and mismatched metadata", async () => {
130
+ await withTempDir(async (root) => {
131
+ const spool = new SessionSpool({rootDir: root})
127
132
 
128
- await assert.rejects(
129
- spool.create({sessionId: "../escape", ownerId: "user-1"}),
130
- /invalid session identifier/i
131
- )
133
+ await expect(
134
+ spool.create({sessionId: "../escape", ownerId: "user-1"})
135
+ ).rejects.toThrow(/invalid session identifier/i)
132
136
 
133
- await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
134
- await assert.rejects(
135
- spool.appendChunk({sessionId: "session_1234567890", ownerId: "other-user", sequence: 1, data: Buffer.from("nope")}),
136
- /does not match spool metadata/i
137
- )
137
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
138
+ await expect(
139
+ spool.appendChunk({sessionId: "session_1234567890", ownerId: "other-user", sequence: 1, data: Buffer.from("nope")})
140
+ ).rejects.toThrow(/does not match spool metadata/i)
141
+ })
138
142
  })
139
- })
140
143
 
141
- test("SessionSpool.remove removes all session spool data and rejects subsequent operations", async () => {
142
- await withTempDir(async (root) => {
143
- const spool = new SessionSpool({rootDir: root})
144
- const sessionId = "session_1234567890"
145
- await spool.create({sessionId, ownerId: "user-1"})
146
- await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("hello")})
144
+ it("SessionSpool.remove removes all session spool data and rejects subsequent operations", async () => {
145
+ await withTempDir(async (root) => {
146
+ const spool = new SessionSpool({rootDir: root})
147
+ const sessionId = "session_1234567890"
148
+ await spool.create({sessionId, ownerId: "user-1"})
149
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("hello")})
147
150
 
148
- const dir = join(root, sessionId)
149
- await access(dir)
151
+ const dir = join(root, sessionId)
152
+ await access(dir)
150
153
 
151
- await spool.remove(sessionId)
154
+ await spool.remove(sessionId)
152
155
 
153
- await assert.rejects(
154
- access(dir),
155
- /ENOENT|no such file/i
156
- )
156
+ await expect(
157
+ access(dir)
158
+ ).rejects.toThrow(/ENOENT|no such file/i)
157
159
 
158
- await assert.rejects(
159
- spool.readChunksAfter(sessionId, 0),
160
- /ENOENT|no such file/i
161
- )
160
+ await expect(
161
+ spool.readChunksAfter(sessionId, 0)
162
+ ).rejects.toThrow(/ENOENT|no such file/i)
163
+ })
162
164
  })
163
- })
164
165
 
165
- test("SessionSpool.remove is safe when session does not exist", async () => {
166
- await withTempDir(async (root) => {
167
- const spool = new SessionSpool({rootDir: root})
166
+ it("SessionSpool.remove is safe when session does not exist", async () => {
167
+ await withTempDir(async (root) => {
168
+ const spool = new SessionSpool({rootDir: root})
168
169
 
169
- // does not throw
170
- await spool.remove("nonexistent_session")
170
+ // does not throw
171
+ await spool.remove("nonexistent_session")
172
+ })
171
173
  })
172
- })
173
174
 
174
- test("SessionSpool enforces maxBytes quota on appendChunk", async () => {
175
- await withTempDir(async (root) => {
176
- const spool = new SessionSpool({rootDir: root, maxBytes: 100})
177
- const sessionId = "session_1234567890"
178
- await spool.create({sessionId, ownerId: "user-1"})
179
-
180
- await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
181
- await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(40)})
182
-
183
- await assert.rejects(
184
- spool.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(30)}),
185
- (err) => {
186
- if (!(err instanceof SpoolQuotaError)) {
187
- return false
188
- }
189
- assert.equal(err.code, "SPOOL_QUOTA_EXCEEDED")
190
- assert.ok(err.message.includes("100"))
191
- return true
175
+ it("SessionSpool enforces maxBytes quota on appendChunk", async () => {
176
+ await withTempDir(async (root) => {
177
+ const spool = new SessionSpool({rootDir: root, maxBytes: 100})
178
+ const sessionId = "session_1234567890"
179
+ await spool.create({sessionId, ownerId: "user-1"})
180
+
181
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
182
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(40)})
183
+
184
+ let error
185
+ try {
186
+ await spool.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(30)})
187
+ } catch (caught) {
188
+ error = caught
192
189
  }
193
- )
190
+ expect(error).toBeDefined()
191
+ expect(error instanceof SpoolQuotaError).toBe(true)
192
+ if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
193
+ expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
194
+ expect(error.message.includes("100")).toBe(true)
195
+ })
194
196
  })
195
- })
196
197
 
197
- test("SessionSpool maxBytes quota does not block under-limit appends", async () => {
198
- await withTempDir(async (root) => {
199
- const spool = new SessionSpool({rootDir: root, maxBytes: 200})
200
- const sessionId = "session_1234567890"
201
- await spool.create({sessionId, ownerId: "user-1"})
198
+ it("SessionSpool maxBytes quota does not block under-limit appends", async () => {
199
+ await withTempDir(async (root) => {
200
+ const spool = new SessionSpool({rootDir: root, maxBytes: 200})
201
+ const sessionId = "session_1234567890"
202
+ await spool.create({sessionId, ownerId: "user-1"})
202
203
 
203
- // exactly at limit
204
- await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(200)})
204
+ // exactly at limit
205
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(200)})
205
206
 
206
- const chunks = await spool.readChunksAfter(sessionId, 0)
207
- assert.equal(chunks.length, 1)
207
+ const chunks = await spool.readChunksAfter(sessionId, 0)
208
+ expect(chunks.length).toBe(1)
209
+ })
208
210
  })
209
- })
210
211
 
211
- test("SessionSpool enforces maxAgeMs quota for aged-out sessions", async () => {
212
- await withTempDir(async (root) => {
213
- let now = 1_000_000
214
- const maxAgeMs = 60_000
215
- const spool = new SessionSpool({rootDir: root, maxAgeMs, now: () => now})
216
-
217
- const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
218
-
219
- now = 1_000_000 + maxAgeMs - 1
220
- await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 1, data: Buffer.from("ok")})
221
-
222
- now = 1_000_000 + maxAgeMs + 1
223
- await assert.rejects(
224
- spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 2, data: Buffer.from("x")}),
225
- (err) => {
226
- if (!(err instanceof SpoolQuotaError)) {
227
- return false
228
- }
229
- assert.equal(err.code, "SPOOL_AGE_EXCEEDED")
230
- return true
212
+ it("SessionSpool enforces maxAgeMs quota for aged-out sessions", async () => {
213
+ await withTempDir(async (root) => {
214
+ let now = 1_000_000
215
+ const maxAgeMs = 60_000
216
+ const spool = new SessionSpool({rootDir: root, maxAgeMs, now: () => now})
217
+
218
+ const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
219
+
220
+ now = 1_000_000 + maxAgeMs - 1
221
+ await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 1, data: Buffer.from("ok")})
222
+
223
+ now = 1_000_000 + maxAgeMs + 1
224
+ let error
225
+ try {
226
+ await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 2, data: Buffer.from("x")})
227
+ } catch (caught) {
228
+ error = caught
231
229
  }
232
- )
230
+ expect(error).toBeDefined()
231
+ expect(error instanceof SpoolQuotaError).toBe(true)
232
+ if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
233
+ expect(error.code).toBe("SPOOL_AGE_EXCEEDED")
234
+ })
233
235
  })
234
- })
235
236
 
236
- test("SessionSpool synchronously rejects expired durable recovery state", async () => {
237
- await withTempDir(async (root) => {
238
- let now = 1_000_000
239
- const spool = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
240
- const sessionId = "session_recovery_expired"
241
- await spool.createNew({sessionId, ownerId: "edge-v3"})
242
- await spool.writeRecoveryState(sessionId, {version: 3})
243
-
244
- now += 60_001
245
- assert.throws(
246
- () => spool.readRecoveryStateSync(sessionId, "edge-v3"),
247
- (error) => error instanceof SpoolQuotaError && error.code === "SPOOL_AGE_EXCEEDED"
248
- )
237
+ it("SessionSpool synchronously rejects expired durable recovery state", async () => {
238
+ await withTempDir(async (root) => {
239
+ let now = 1_000_000
240
+ const spool = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
241
+ const sessionId = "session_recovery_expired"
242
+ await spool.createNew({sessionId, ownerId: "edge-v3"})
243
+ await spool.writeRecoveryState(sessionId, {version: 3})
244
+
245
+ now += 60_001
246
+ let error
247
+ try {
248
+ spool.readRecoveryStateSync(sessionId, "edge-v3")
249
+ } catch (caught) {
250
+ error = caught
251
+ }
252
+ expect(error).toBeDefined()
253
+ expect(error instanceof SpoolQuotaError).toBe(true)
254
+ if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
255
+ expect(error.code).toBe("SPOOL_AGE_EXCEEDED")
256
+ })
249
257
  })
250
- })
251
258
 
252
- test("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
253
- await withTempDir(async (root) => {
254
- const sessionId = "session_recovery_chunks"
255
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
256
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
257
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
258
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
259
-
260
- const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
261
-
262
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
263
- assert.equal(chunks[0]?.data.toString(), "new")
264
- assert.equal(spool.getUsedBytes(sessionId), 3)
265
- assert.throws(
266
- () => spool.loadChunksSync(sessionId, "other-owner", 3, 6),
267
- /Invalid Socketduct recovery chunks/
268
- )
259
+ it("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
260
+ await withTempDir(async (root) => {
261
+ const sessionId = "session_recovery_chunks"
262
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
263
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
264
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
265
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
266
+
267
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
268
+
269
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([6])
270
+ expect(chunks[0]?.data.toString()).toBe("new")
271
+ expect(spool.getUsedBytes(sessionId)).toBe(3)
272
+ expect(
273
+ () => spool.loadChunksSync(sessionId, "other-owner", 3, 6)
274
+ ).toThrow(/Invalid Socketduct recovery chunks/)
275
+ })
269
276
  })
270
- })
271
277
 
272
- test("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
273
- await withTempDir(async (root) => {
274
- const sessionId = "session_append_interruption"
275
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
276
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
277
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
278
- const chunksPath = join(root, sessionId, "chunks.jsonl")
279
- const committed = await readFile(chunksPath, "utf8")
280
- await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
281
-
282
- const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
283
-
284
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
285
- assert.equal(await readFile(chunksPath, "utf8"), committed)
286
- assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
287
- assert.equal((await stat(chunksPath)).mode & 0o777, 0o600)
288
- assert.equal((await stat(join(root, sessionId, "usage.json"))).mode & 0o777, 0o600)
278
+ it("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
279
+ await withTempDir(async (root) => {
280
+ const sessionId = "session_append_interruption"
281
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
282
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
283
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
284
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
285
+ const committed = await readFile(chunksPath, "utf8")
286
+ await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
287
+
288
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
289
+
290
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([3])
291
+ expect(await readFile(chunksPath, "utf8")).toBe(committed)
292
+ expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
293
+ expect((await stat(chunksPath)).mode & 0o777).toBe(0o600)
294
+ expect((await stat(join(root, sessionId, "usage.json"))).mode & 0o777).toBe(0o600)
295
+ })
289
296
  })
290
- })
291
297
 
292
- test("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
293
- await withTempDir(async (root) => {
294
- const sessionId = "session_usage_interruption"
295
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
296
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
297
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
298
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
298
+ it("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
299
+ await withTempDir(async (root) => {
300
+ const sessionId = "session_usage_interruption"
301
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
302
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
303
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
304
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
299
305
 
300
- const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
306
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
301
307
 
302
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
303
- assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
308
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([3])
309
+ expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
310
+ })
304
311
  })
305
- })
306
312
 
307
- test("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
308
- await withTempDir(async (root) => {
309
- const sessionId = "session_truncate_interruption"
310
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
311
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
312
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
313
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
314
- const chunksPath = join(root, sessionId, "chunks.jsonl")
315
- await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
313
+ it("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
314
+ await withTempDir(async (root) => {
315
+ const sessionId = "session_truncate_interruption"
316
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
317
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
318
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
319
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
320
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
321
+ await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
316
322
 
317
- const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
323
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
318
324
 
319
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
320
- assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
325
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([6])
326
+ expect(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8"))).toEqual({totalBytes: 3})
327
+ })
321
328
  })
322
- })
323
329
 
324
- test("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
325
- await withTempDir(async (root) => {
326
- const sessionId = "session_partial_interruption"
327
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
328
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
329
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
330
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
331
- const chunksPath = join(root, sessionId, "chunks.jsonl")
332
- const committed = await readFile(chunksPath, "utf8")
333
- await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
334
-
335
- const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
336
-
337
- assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3, 6])
338
- assert.equal(await readFile(chunksPath, "utf8"), committed)
330
+ it("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
331
+ await withTempDir(async (root) => {
332
+ const sessionId = "session_partial_interruption"
333
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
334
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
335
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
336
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
337
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
338
+ const committed = await readFile(chunksPath, "utf8")
339
+ await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
340
+
341
+ const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
342
+
343
+ expect(chunks.map((chunk) => chunk.sequence)).toEqual([3, 6])
344
+ expect(await readFile(chunksPath, "utf8")).toBe(committed)
345
+ })
339
346
  })
340
- })
341
347
 
342
- test("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
343
- await withTempDir(async (root) => {
344
- const sessionId = "session_committed_corruption"
345
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
346
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
347
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
348
- const chunksPath = join(root, sessionId, "chunks.jsonl")
349
- await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
350
-
351
- assert.throws(
352
- () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6),
353
- /Invalid Socketduct recovery chunks/
354
- )
355
- })
348
+ it("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
349
+ await withTempDir(async (root) => {
350
+ const sessionId = "session_committed_corruption"
351
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
352
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
353
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
354
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
355
+ await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
356
+
357
+ expect(
358
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
359
+ ).toThrow(/Invalid Socketduct recovery chunks/)
360
+ })
356
361
 
357
- await withTempDir(async (root) => {
358
- const sessionId = "session_tail_corruption"
359
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
360
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
361
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
362
- const chunksPath = join(root, sessionId, "chunks.jsonl")
363
- await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
364
-
365
- assert.throws(
366
- () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
367
- /Invalid Socketduct recovery chunks/
368
- )
369
- })
362
+ await withTempDir(async (root) => {
363
+ const sessionId = "session_tail_corruption"
364
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
365
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
366
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
367
+ const chunksPath = join(root, sessionId, "chunks.jsonl")
368
+ await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
369
+
370
+ expect(
371
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
372
+ ).toThrow(/Invalid Socketduct recovery chunks/)
373
+ })
374
+
375
+ await withTempDir(async (root) => {
376
+ const sessionId = "session_usage_corruption"
377
+ const spool = new SessionSpool({rootDir: root, maxBytes: 16})
378
+ await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
379
+ await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
380
+ await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
370
381
 
371
- await withTempDir(async (root) => {
372
- const sessionId = "session_usage_corruption"
373
- const spool = new SessionSpool({rootDir: root, maxBytes: 16})
374
- await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
375
- await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
376
- await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
377
-
378
- assert.throws(
379
- () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
380
- /Invalid Socketduct recovery chunks/
381
- )
382
+ expect(
383
+ () => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
384
+ ).toThrow(/Invalid Socketduct recovery chunks/)
385
+ })
382
386
  })
383
- })
384
387
 
385
- test("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
386
- await withTempDir(async (root) => {
387
- let now = 1_000_000
388
- const sessionId = "session_abandoned_expired"
389
- const first = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
390
- await first.createNew({sessionId, ownerId: "edge-v3"})
391
-
392
- now += 60_001
393
- const replacement = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
394
- await replacement.createNew({sessionId, ownerId: "edge-v3"})
395
- await access(join(root, sessionId))
388
+ it("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
389
+ await withTempDir(async (root) => {
390
+ let now = 1_000_000
391
+ const sessionId = "session_abandoned_expired"
392
+ const first = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
393
+ await first.createNew({sessionId, ownerId: "edge-v3"})
394
+
395
+ now += 60_001
396
+ const replacement = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
397
+ await replacement.createNew({sessionId, ownerId: "edge-v3"})
398
+ await access(join(root, sessionId))
399
+ })
396
400
  })
397
- })
398
401
 
399
- test("SessionSpool appendChunk appends lines without rewriting whole file", async () => {
400
- await withTempDir(async (root) => {
401
- const spool = new SessionSpool({rootDir: root})
402
- const sessionId = "session_1234567890"
403
- await spool.create({sessionId, ownerId: "user-1"})
404
-
405
- await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("first")})
406
- await spool.appendChunk({sessionId, sequence: 2, data: Buffer.from("second")})
407
- await spool.appendChunk({sessionId, sequence: 3, data: Buffer.from("third")})
408
-
409
- const raw = await readFile(join(root, sessionId, "chunks.jsonl"), "utf8")
410
- const lines = raw.trimEnd().split("\n")
411
-
412
- assert.ok(lines.length >= 3)
413
- const chunk1 = JSON.parse(/** @type {string} */ (lines[0]))
414
- const chunk2 = JSON.parse(/** @type {string} */ (lines[1]))
415
- const chunk3 = JSON.parse(/** @type {string} */ (lines[2]))
416
- assert.equal(chunk1.sequence, 1)
417
- assert.equal(chunk2.sequence, 2)
418
- assert.equal(chunk3.sequence, 3)
402
+ it("SessionSpool appendChunk appends lines without rewriting whole file", async () => {
403
+ await withTempDir(async (root) => {
404
+ const spool = new SessionSpool({rootDir: root})
405
+ const sessionId = "session_1234567890"
406
+ await spool.create({sessionId, ownerId: "user-1"})
407
+
408
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("first")})
409
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.from("second")})
410
+ await spool.appendChunk({sessionId, sequence: 3, data: Buffer.from("third")})
411
+
412
+ const raw = await readFile(join(root, sessionId, "chunks.jsonl"), "utf8")
413
+ const lines = raw.trimEnd().split("\n")
414
+
415
+ expect(lines.length >= 3).toBeTruthy()
416
+ const chunk1 = JSON.parse(/** @type {string} */ (lines[0]))
417
+ const chunk2 = JSON.parse(/** @type {string} */ (lines[1]))
418
+ const chunk3 = JSON.parse(/** @type {string} */ (lines[2]))
419
+ expect(chunk1.sequence).toBe(1)
420
+ expect(chunk2.sequence).toBe(2)
421
+ expect(chunk3.sequence).toBe(3)
422
+ })
419
423
  })
420
- })
421
424
 
422
- test("SessionSpool getUsedBytes tracks per-session spool byte usage", async () => {
423
- await withTempDir(async (root) => {
424
- const spool = new SessionSpool({rootDir: root, maxBytes: 1000})
425
- const sessionId = "session_1234567890"
426
- await spool.create({sessionId, ownerId: "user-1"})
425
+ it("SessionSpool getUsedBytes tracks per-session spool byte usage", async () => {
426
+ await withTempDir(async (root) => {
427
+ const spool = new SessionSpool({rootDir: root, maxBytes: 1000})
428
+ const sessionId = "session_1234567890"
429
+ await spool.create({sessionId, ownerId: "user-1"})
427
430
 
428
- assert.equal(spool.getUsedBytes(sessionId), 0)
431
+ expect(spool.getUsedBytes(sessionId)).toBe(0)
429
432
 
430
- await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
431
- assert.equal(spool.getUsedBytes(sessionId), 50)
433
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
434
+ expect(spool.getUsedBytes(sessionId)).toBe(50)
432
435
 
433
- await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
434
- assert.equal(spool.getUsedBytes(sessionId), 80)
436
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
437
+ expect(spool.getUsedBytes(sessionId)).toBe(80)
435
438
 
436
- await spool.truncateAcked(sessionId, 1)
437
- assert.equal(spool.getUsedBytes(sessionId), 30)
439
+ await spool.truncateAcked(sessionId, 1)
440
+ expect(spool.getUsedBytes(sessionId)).toBe(30)
441
+ })
438
442
  })
439
- })
440
443
 
441
- test("SessionSpool getUsedBytes returns undefined for non-existent session", () => {
442
- const spool = new SessionSpool({rootDir: "/tmp"})
443
- assert.equal(spool.getUsedBytes("nonexistent_session"), undefined)
444
- })
445
-
446
- test("SessionSpool reopened with existing data enforces maxBytes quota from durable usage", async () => {
447
- await withTempDir(async (root) => {
448
- const sessionId = "session_1234567890"
444
+ it("SessionSpool getUsedBytes returns undefined for non-existent session", () => {
445
+ const spool = new SessionSpool({rootDir: "/tmp"})
446
+ expect(spool.getUsedBytes("nonexistent_session")).toBeUndefined()
447
+ })
449
448
 
450
- const spool1 = new SessionSpool({rootDir: root, maxBytes: 100})
451
- await spool1.create({sessionId, ownerId: "user-1"})
452
- await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(60)})
449
+ it("SessionSpool reopened with existing data enforces maxBytes quota from durable usage", async () => {
450
+ await withTempDir(async (root) => {
451
+ const sessionId = "session_1234567890"
453
452
 
454
- const spool2 = new SessionSpool({rootDir: root, maxBytes: 100})
455
- await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
453
+ const spool1 = new SessionSpool({rootDir: root, maxBytes: 100})
454
+ await spool1.create({sessionId, ownerId: "user-1"})
455
+ await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(60)})
456
456
 
457
- await assert.rejects(
458
- spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(20)}),
459
- (err) => err instanceof SpoolQuotaError && err.code === "SPOOL_QUOTA_EXCEEDED"
460
- )
457
+ const spool2 = new SessionSpool({rootDir: root, maxBytes: 100})
458
+ await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
461
459
 
462
- const chunks = await spool2.readChunksAfter(sessionId, 0)
463
- assert.equal(chunks.length, 2)
464
- assert.equal(chunks[0]?.sequence, 1)
465
- assert.equal(chunks[1]?.sequence, 2)
460
+ let error
461
+ try {
462
+ await spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(20)})
463
+ } catch (caught) {
464
+ error = caught
465
+ }
466
+ expect(error).toBeDefined()
467
+ expect(error instanceof SpoolQuotaError).toBe(true)
468
+ if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
469
+ expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
470
+
471
+ const chunks = await spool2.readChunksAfter(sessionId, 0)
472
+ expect(chunks.length).toBe(2)
473
+ expect(chunks[0]?.sequence).toBe(1)
474
+ expect(chunks[1]?.sequence).toBe(2)
475
+ })
466
476
  })
467
- })
468
477
 
469
- test("SessionSpool reconciles a stale usage sidecar from durable chunks when reopened", async () => {
470
- await withTempDir(async (root) => {
471
- const sessionId = "session_1234567890"
472
- const spool1 = new SessionSpool({rootDir: root, maxBytes: 20})
473
- await spool1.create({sessionId, ownerId: "user-1"})
474
- await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(10)})
475
- await writeFile(join(root, sessionId, "usage.json"), JSON.stringify({totalBytes: 20}) + "\n")
476
-
477
- const spool2 = new SessionSpool({rootDir: root, maxBytes: 20})
478
- await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(10)})
479
-
480
- await assert.rejects(
481
- spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(1)}),
482
- (err) => err instanceof SpoolQuotaError && err.code === "SPOOL_QUOTA_EXCEEDED"
483
- )
484
- assert.deepEqual((await spool2.readChunksAfter(sessionId, 0)).map((chunk) => chunk.data.length), [10, 10])
478
+ it("SessionSpool reconciles a stale usage sidecar from durable chunks when reopened", async () => {
479
+ await withTempDir(async (root) => {
480
+ const sessionId = "session_1234567890"
481
+ const spool1 = new SessionSpool({rootDir: root, maxBytes: 20})
482
+ await spool1.create({sessionId, ownerId: "user-1"})
483
+ await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(10)})
484
+ await writeFile(join(root, sessionId, "usage.json"), JSON.stringify({totalBytes: 20}) + "\n")
485
+
486
+ const spool2 = new SessionSpool({rootDir: root, maxBytes: 20})
487
+ await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(10)})
488
+
489
+ let error
490
+ try {
491
+ await spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(1)})
492
+ } catch (caught) {
493
+ error = caught
494
+ }
495
+ expect(error).toBeDefined()
496
+ expect(error instanceof SpoolQuotaError).toBe(true)
497
+ if (!(error instanceof SpoolQuotaError)) throw new Error("Expected a SpoolQuotaError")
498
+ expect(error.code).toBe("SPOOL_QUOTA_EXCEEDED")
499
+ expect((await spool2.readChunksAfter(sessionId, 0)).map((chunk) => chunk.data.length)).toEqual([10, 10])
500
+ })
485
501
  })
486
502
  })