@tranquilload/core 0.1.0 → 0.1.1

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 (55) hide show
  1. package/.turbo/turbo-build.log +68 -68
  2. package/CHANGELOG.md +67 -0
  3. package/dist/errors.cjs +3 -1
  4. package/dist/errors.d.cts +2 -2
  5. package/dist/errors.d.mts +2 -2
  6. package/dist/errors.mjs +2 -2
  7. package/dist/index-BaeUV_fj.d.cts +139 -0
  8. package/dist/index-BaeUV_fj.d.cts.map +1 -0
  9. package/dist/index-bpWq6tje.d.mts +139 -0
  10. package/dist/index-bpWq6tje.d.mts.map +1 -0
  11. package/dist/multipart.cjs +80 -12
  12. package/dist/multipart.cjs.map +1 -1
  13. package/dist/multipart.d.cts +2 -2
  14. package/dist/multipart.d.mts +2 -2
  15. package/dist/multipart.mjs +80 -12
  16. package/dist/multipart.mjs.map +1 -1
  17. package/dist/{normalize-callback-BNBZZ1jT.cjs → normalize-callback-BdLtk9jb.cjs} +2 -2
  18. package/dist/{normalize-callback-BNBZZ1jT.cjs.map → normalize-callback-BdLtk9jb.cjs.map} +1 -1
  19. package/dist/{normalize-callback-DQ6C4gaV.mjs → normalize-callback-tcZ_nyq5.mjs} +2 -2
  20. package/dist/{normalize-callback-DQ6C4gaV.mjs.map → normalize-callback-tcZ_nyq5.mjs.map} +1 -1
  21. package/dist/oneshot.cjs +2 -2
  22. package/dist/oneshot.d.cts +2 -2
  23. package/dist/oneshot.d.mts +2 -2
  24. package/dist/oneshot.mjs +2 -2
  25. package/dist/progress.d.cts +2 -2
  26. package/dist/progress.d.mts +2 -2
  27. package/dist/{upload-error-BUexBh08.cjs → upload-error-BG1dOOl3.cjs} +26 -1
  28. package/dist/upload-error-BG1dOOl3.cjs.map +1 -0
  29. package/dist/{upload-error-B2ISUc_k.d.cts → upload-error-DTYVNlaJ.d.cts} +19 -3
  30. package/dist/{upload-error-B2ISUc_k.d.cts.map → upload-error-DTYVNlaJ.d.cts.map} +1 -1
  31. package/dist/{upload-error-zDvpxT9X.mjs → upload-error-Dbz_9j81.mjs} +21 -2
  32. package/dist/upload-error-Dbz_9j81.mjs.map +1 -0
  33. package/dist/{upload-error-jol-eoDW.d.mts → upload-error-jBco270d.d.mts} +19 -3
  34. package/dist/{upload-error-jol-eoDW.d.mts.map → upload-error-jBco270d.d.mts.map} +1 -1
  35. package/dist/{upload-event-C9TOVp5l.d.mts → upload-event-BT_nXgM9.d.cts} +7 -1
  36. package/dist/upload-event-BT_nXgM9.d.cts.map +1 -0
  37. package/dist/{upload-event-D77olieX.d.cts → upload-event-DOGbegxa.d.mts} +7 -1
  38. package/dist/upload-event-DOGbegxa.d.mts.map +1 -0
  39. package/package.json +1 -1
  40. package/src/errors/index.ts +2 -0
  41. package/src/errors/upload-error.test.ts +35 -0
  42. package/src/errors/upload-error.ts +27 -0
  43. package/src/multipart/index.test.ts +164 -1
  44. package/src/multipart/index.ts +96 -5
  45. package/src/multipart/upload-stream.test.ts +201 -2
  46. package/src/multipart/upload-stream.ts +160 -16
  47. package/src/progress/upload-event.ts +6 -0
  48. package/dist/index-Ch8xM6Xt.d.cts +0 -60
  49. package/dist/index-Ch8xM6Xt.d.cts.map +0 -1
  50. package/dist/index-DBGtgXEd.d.mts +0 -60
  51. package/dist/index-DBGtgXEd.d.mts.map +0 -1
  52. package/dist/upload-error-BUexBh08.cjs.map +0 -1
  53. package/dist/upload-error-zDvpxT9X.mjs.map +0 -1
  54. package/dist/upload-event-C9TOVp5l.d.mts.map +0 -1
  55. package/dist/upload-event-D77olieX.d.cts.map +0 -1
@@ -1,11 +1,16 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { Cause, Effect, Exit, Option } from "effect"
3
+ import { afterEach, vi } from "vitest"
3
4
  import { AbortError, CompleteUploadError, InitiateUploadError } from "../errors/upload-error.js"
4
5
  import { compress } from "../pipeline/compress.js"
5
6
  import { compose, type Transform } from "../pipeline/middleware.js"
6
- import { uploadMultipart } from "./index.js"
7
+ import { uploadMultipart, type ResumeState } from "./index.js"
7
8
  import { uploadMultipartEffect } from "./upload-stream.js"
8
9
 
10
+ afterEach(() => {
11
+ vi.restoreAllMocks()
12
+ })
13
+
9
14
  // Helper: create a ReadableStream from a Uint8Array
10
15
  const fromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
11
16
  new ReadableStream({
@@ -281,3 +286,161 @@ describe("uploadMultipart — Dual API entry point", () => {
281
286
  })
282
287
  )
283
288
  })
289
+
290
+ describe("uploadMultipart — resumeState surface", () => {
291
+ it.effect("resumeState resolves with correct shape on fresh init (no digest)", () =>
292
+ Effect.gen(function* () {
293
+ const { result, resumeState } = uploadMultipart({
294
+ stream: fromBytes(new Uint8Array(10).fill(1)),
295
+ chunkSize: 10,
296
+ initiate: () => ({ uploadId: "fresh-up-1" }),
297
+ pipelineIdentity: "ident-1",
298
+ uploadPart: () => "etag-1",
299
+ completeUpload: () => {},
300
+ })
301
+
302
+ yield* Effect.promise(() => result)
303
+ const state = yield* Effect.promise(() => resumeState)
304
+ expect(state).toEqual({
305
+ version: 1,
306
+ uploadId: "fresh-up-1",
307
+ chunkSize: 10,
308
+ pipelineIdentity: "ident-1",
309
+ contentDigestCaptured: false,
310
+ })
311
+ })
312
+ )
313
+
314
+ it.effect("resumeState includes contentDigest when getContentDigest is provided (fresh init)", () =>
315
+ Effect.gen(function* () {
316
+ const { result, resumeState } = uploadMultipart({
317
+ stream: fromBytes(new Uint8Array(10).fill(1)),
318
+ chunkSize: 10,
319
+ initiate: () => ({ uploadId: "fresh-up-2" }),
320
+ getContentDigest: () => "digest-xyz",
321
+ uploadPart: () => "etag-1",
322
+ completeUpload: () => {},
323
+ })
324
+
325
+ yield* Effect.promise(() => result)
326
+ const state = yield* Effect.promise(() => resumeState)
327
+ expect(state).toMatchObject({
328
+ version: 1,
329
+ uploadId: "fresh-up-2",
330
+ contentDigest: "digest-xyz",
331
+ contentDigestCaptured: true,
332
+ })
333
+ })
334
+ )
335
+
336
+ it.effect("resumeState resolves with the passed resumeFrom shape on resume", () =>
337
+ Effect.gen(function* () {
338
+ const passed: ResumeState = {
339
+ version: 1,
340
+ uploadId: "stored-up-1",
341
+ chunkSize: 10,
342
+ contentDigestCaptured: false,
343
+ }
344
+ const { result, resumeState, uploadId } = uploadMultipart({
345
+ stream: fromBytes(new Uint8Array(10).fill(1)),
346
+ chunkSize: 10,
347
+ uploadPart: () => "etag-1",
348
+ completeUpload: () => {},
349
+ resumeFrom: passed,
350
+ })
351
+
352
+ // uploadId resolves synchronously (AC22) — no need to await `result`
353
+ const id = yield* Effect.promise(() => uploadId)
354
+ expect(id).toBe("stored-up-1")
355
+
356
+ yield* Effect.promise(() => result)
357
+ const state = yield* Effect.promise(() => resumeState)
358
+ expect(state).toBe(passed)
359
+ })
360
+ )
361
+ })
362
+
363
+ describe("uploadMultipart — legacy-pattern warn", () => {
364
+ it("warns once when initiate + reconcile + no resumeFrom (AC16)", async () => {
365
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
366
+
367
+ const { result } = uploadMultipart({
368
+ stream: fromBytes(new Uint8Array(10).fill(1)),
369
+ chunkSize: 10,
370
+ initiate: () => ({ uploadId: "u-1" }),
371
+ reconcileCompletedParts: () => [{ partNumber: 1, etag: "etag-1" }],
372
+ uploadPart: () => "should-not-be-called",
373
+ completeUpload: () => {},
374
+ })
375
+ await result
376
+
377
+ const legacyWarns = warnSpy.mock.calls.filter((c) =>
378
+ typeof c[0] === "string" && c[0].startsWith("Tranquilload: detected legacy resume pattern")
379
+ )
380
+ expect(legacyWarns).toHaveLength(1)
381
+ })
382
+
383
+ it("does NOT warn when resumeFrom is provided alongside initiate + reconcile (AC17)", async () => {
384
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
385
+
386
+ const { result } = uploadMultipart({
387
+ stream: fromBytes(new Uint8Array(10).fill(1)),
388
+ chunkSize: 10,
389
+ initiate: () => ({ uploadId: "ignored" }),
390
+ reconcileCompletedParts: () => [{ partNumber: 1, etag: "etag-1" }],
391
+ uploadPart: () => "should-not-be-called",
392
+ completeUpload: () => {},
393
+ resumeFrom: {
394
+ version: 1,
395
+ uploadId: "stored",
396
+ chunkSize: 10,
397
+ contentDigestCaptured: false,
398
+ },
399
+ })
400
+ await result
401
+
402
+ const legacyWarns = warnSpy.mock.calls.filter((c) =>
403
+ typeof c[0] === "string" && c[0].startsWith("Tranquilload: detected legacy resume pattern")
404
+ )
405
+ expect(legacyWarns).toHaveLength(0)
406
+ })
407
+
408
+ it("warns even when reconcileCompletedParts returns an empty array (AC18)", async () => {
409
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
410
+
411
+ const { result } = uploadMultipart({
412
+ stream: fromBytes(new Uint8Array(10).fill(1)),
413
+ chunkSize: 10,
414
+ initiate: () => ({ uploadId: "u-1" }),
415
+ reconcileCompletedParts: () => [],
416
+ uploadPart: () => "etag-1",
417
+ completeUpload: () => {},
418
+ })
419
+ await result
420
+
421
+ const legacyWarns = warnSpy.mock.calls.filter((c) =>
422
+ typeof c[0] === "string" && c[0].startsWith("Tranquilload: detected legacy resume pattern")
423
+ )
424
+ expect(legacyWarns).toHaveLength(1)
425
+ })
426
+
427
+ it("warns when pipeline is set without pipelineIdentity (AC24)", async () => {
428
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
429
+
430
+ const noopTransform: Transform = (stream) => stream
431
+
432
+ const { result } = uploadMultipart({
433
+ stream: fromBytes(new Uint8Array(10).fill(1)),
434
+ chunkSize: 10,
435
+ pipeline: noopTransform,
436
+ uploadPart: () => "etag-1",
437
+ completeUpload: () => {},
438
+ })
439
+ await result
440
+
441
+ const identityWarns = warnSpy.mock.calls.filter((c) =>
442
+ typeof c[0] === "string" && c[0].includes("pipeline is set but pipelineIdentity is not")
443
+ )
444
+ expect(identityWarns).toHaveLength(1)
445
+ })
446
+ })
@@ -3,10 +3,10 @@ import type { UploadCompleted, UploadEvent } from "../progress/upload-event.js"
3
3
  import type { Transform } from "../pipeline/middleware.js"
4
4
  import { CompressionServiceLive } from "../services/compression-service.js"
5
5
  import { LoggerServiceLive } from "../services/logger-service.js"
6
- import { uploadMultipartEffect, type CompletedPart, type UploadMultipartOptions } from "./upload-stream.js"
6
+ import { uploadMultipartEffect, type CompletedPart, type ResumeState, type UploadMultipartOptions } from "./upload-stream.js"
7
7
 
8
8
  export type UploadResult = UploadCompleted
9
- export type { CompletedPart, UploadMultipartOptions }
9
+ export type { CompletedPart, ResumeState, UploadMultipartOptions }
10
10
 
11
11
  export interface Progress {
12
12
  readonly bytesUploaded: number
@@ -18,6 +18,9 @@ export interface MultipartPublicOptions extends UploadMultipartOptions {
18
18
  readonly pipeline?: Transform | Effect.Effect<Transform, unknown, unknown>
19
19
  }
20
20
 
21
+ const NO_RESUME_CONTEXT_ERROR_MESSAGE =
22
+ "uploadMultipart: resumeState is only available when `initiate` or `resumeFrom` is provided"
23
+
21
24
  export const uploadMultipart = (
22
25
  options: MultipartPublicOptions
23
26
  ): {
@@ -25,7 +28,34 @@ export const uploadMultipart = (
25
28
  result: Promise<UploadResult>
26
29
  getProgress: (() => Promise<Progress>) & { effect: Effect.Effect<Progress> }
27
30
  uploadId: Promise<string>
31
+ resumeState: Promise<ResumeState>
28
32
  } => {
33
+ // Legacy-pattern detection: warns unconditionally so first-time-after-upgrade
34
+ // users also see the migration message (per G3 — empty reconcile would have
35
+ // hidden the warning under the old "warn when reconcile returned >= 1" rule).
36
+ if (
37
+ options.initiate !== undefined &&
38
+ options.reconcileCompletedParts !== undefined &&
39
+ options.resumeFrom === undefined
40
+ ) {
41
+ console.warn(
42
+ "Tranquilload: detected legacy resume pattern. You're passing `initiate` " +
43
+ "and `reconcileCompletedParts` without `resumeFrom: ResumeState`. The new " +
44
+ "API requires the persisted ResumeState to validate chunkSize/pipeline/" +
45
+ "digest match across sessions. See MIGRATION.md for migration steps."
46
+ )
47
+ }
48
+ // Pipeline-without-identity: the resume validation cannot detect a pipeline
49
+ // mismatch across sessions when the user runs a pipeline but provides no
50
+ // identity. We warn so the user is aware their resume safety is reduced.
51
+ if (options.pipeline !== undefined && options.pipelineIdentity === undefined) {
52
+ console.warn(
53
+ "Tranquilload: pipeline is set but pipelineIdentity is not. Without an " +
54
+ "identity, the resume validation cannot detect a pipeline mismatch " +
55
+ "across sessions. See README → Resume Safety."
56
+ )
57
+ }
58
+
29
59
  const refProgress = Effect.runSync(
30
60
  Ref.make<Progress>({
31
61
  bytesUploaded: 0,
@@ -38,6 +68,32 @@ export const uploadMultipart = (
38
68
  resolveUploadId = resolve
39
69
  })
40
70
 
71
+ let resolveResumeState!: (s: ResumeState) => void
72
+ let rejectResumeState!: (e: unknown) => void
73
+ let resumeStateSettled = false
74
+ const resumeStatePromise: Promise<ResumeState> = new Promise<ResumeState>((resolve, reject) => {
75
+ resolveResumeState = (s) => {
76
+ if (resumeStateSettled) return
77
+ resumeStateSettled = true
78
+ resolve(s)
79
+ }
80
+ rejectResumeState = (e) => {
81
+ if (resumeStateSettled) return
82
+ resumeStateSettled = true
83
+ reject(e)
84
+ }
85
+ })
86
+ // Avoid unhandled-rejection warnings: callers may not await resumeState.
87
+ resumeStatePromise.catch(() => {})
88
+
89
+ // Resume branch: resolve uploadId AND resumeState synchronously before the
90
+ // stream runs. uploadId per AC22; resumeState per Task 4.1 (the lib has no
91
+ // new state to add — the user already has the value they passed in).
92
+ if (options.resumeFrom !== undefined) {
93
+ resolveUploadId(options.resumeFrom.uploadId)
94
+ resolveResumeState(options.resumeFrom)
95
+ }
96
+
41
97
  const collected: Promise<ReadonlyArray<UploadEvent>> = (async () => {
42
98
  // Step 1: resolve pipeline to get the processed stream
43
99
  let processedStream = options.stream
@@ -60,7 +116,24 @@ export const uploadMultipart = (
60
116
  const program = uploadMultipartEffect({ ...options, stream: processedStream }).pipe(
61
117
  Stream.tap((event) => {
62
118
  if (event._tag === "UploadInitiated") {
63
- return Effect.sync(() => resolveUploadId(event.uploadId))
119
+ // Fresh-init branch: resolve uploadId on the event, and build the
120
+ // ResumeState from the event payload + caller-supplied fields.
121
+ return Effect.sync(() => {
122
+ resolveUploadId(event.uploadId)
123
+ const state: ResumeState = {
124
+ version: 1,
125
+ uploadId: event.uploadId,
126
+ chunkSize: options.chunkSize,
127
+ ...(options.pipelineIdentity !== undefined
128
+ ? { pipelineIdentity: options.pipelineIdentity }
129
+ : {}),
130
+ ...(event.contentDigest !== undefined
131
+ ? { contentDigest: event.contentDigest }
132
+ : {}),
133
+ contentDigestCaptured: options.getContentDigest !== undefined,
134
+ }
135
+ resolveResumeState(state)
136
+ })
64
137
  }
65
138
  if (event._tag === "PartCompleted") {
66
139
  return Ref.update(refProgress, (p) => ({
@@ -82,7 +155,19 @@ export const uploadMultipart = (
82
155
  })()
83
156
 
84
157
  // Rejection is surfaced via `result`; suppress the propagated rejection from .finally()
85
- collected.finally(() => resolveUploadId("")).catch(() => {})
158
+ collected
159
+ .then(() => {
160
+ // Success-but-no-context: neither initiate nor resumeFrom produced state.
161
+ if (!resumeStateSettled) {
162
+ rejectResumeState(new Error(NO_RESUME_CONTEXT_ERROR_MESSAGE))
163
+ }
164
+ })
165
+ .catch((err) => {
166
+ if (!resumeStateSettled) {
167
+ rejectResumeState(err)
168
+ }
169
+ })
170
+ .finally(() => resolveUploadId(""))
86
171
 
87
172
  // events: ReadableStream built from collected array; closes cleanly on error
88
173
  const events = new ReadableStream<UploadEvent>({
@@ -112,7 +197,13 @@ export const uploadMultipart = (
112
197
  { effect: Ref.get(refProgress) }
113
198
  )
114
199
 
115
- return { events, result, getProgress, uploadId: uploadIdPromise }
200
+ return {
201
+ events,
202
+ result,
203
+ getProgress,
204
+ uploadId: uploadIdPromise,
205
+ resumeState: resumeStatePromise,
206
+ }
116
207
  }
117
208
 
118
209
  // Effect escape hatch — LoggerService layer left open for user composition
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import { Cause, Effect, Fiber, Ref, Schedule, Stream, TestClock } from "effect"
3
- import { AbortError, CircuitOpenError, CompleteUploadError, MaxRetriesExceededError, PartUploadError, PresignedUrlError, ReconcileError } from "../errors/upload-error.js"
3
+ import { AbortError, CircuitOpenError, CompleteUploadError, MaxRetriesExceededError, PartUploadError, PresignedUrlError, ReconcileError, ResumeMismatchError } from "../errors/upload-error.js"
4
4
  import type { UploadEvent } from "../progress/upload-event.js"
5
5
  import { LoggerServiceLive } from "../services/logger-service.js"
6
- import { uploadMultipartEffect, type CompletedPart } from "./upload-stream.js"
6
+ import { uploadMultipartEffect, type CompletedPart, type ResumeState } from "./upload-stream.js"
7
7
 
8
8
  const fromBytes = (bytes: Uint8Array): ReadableStream<Uint8Array> =>
9
9
  new ReadableStream({ start: c => { c.enqueue(bytes); c.close() } })
@@ -333,4 +333,203 @@ describe("uploadMultipartEffect with circuitBreaker", () => {
333
333
  expect(err).toBeInstanceOf(CircuitOpenError)
334
334
  })
335
335
  )
336
+
337
+ describe("chunkSize validation", () => {
338
+ const baseOptions = {
339
+ stream: fromBytes(new Uint8Array(10)),
340
+ uploadPart: () => "etag",
341
+ completeUpload: () => {},
342
+ } as const
343
+
344
+ it("throws TypeError when chunkSize is 0", () => {
345
+ expect(() => uploadMultipartEffect({ ...baseOptions, chunkSize: 0 }))
346
+ .toThrow(TypeError)
347
+ })
348
+
349
+ it("throws TypeError when chunkSize is negative", () => {
350
+ expect(() => uploadMultipartEffect({ ...baseOptions, chunkSize: -1 }))
351
+ .toThrow(/positive finite number/)
352
+ })
353
+
354
+ it("throws TypeError when chunkSize is NaN", () => {
355
+ expect(() => uploadMultipartEffect({ ...baseOptions, chunkSize: NaN }))
356
+ .toThrow(/positive finite number/)
357
+ })
358
+
359
+ it("throws TypeError when chunkSize is Infinity", () => {
360
+ expect(() => uploadMultipartEffect({ ...baseOptions, chunkSize: Infinity }))
361
+ .toThrow(/positive finite number/)
362
+ })
363
+
364
+ it("accepts a positive integer chunkSize (control)", () => {
365
+ expect(() => uploadMultipartEffect({ ...baseOptions, chunkSize: 5 }))
366
+ .not.toThrow()
367
+ })
368
+ })
369
+ })
370
+
371
+ describe("ResumeState validation", () => {
372
+ const baseOptions = {
373
+ stream: new ReadableStream<Uint8Array>({ start: (c) => { c.enqueue(new Uint8Array(10)); c.close() } }),
374
+ chunkSize: 10,
375
+ uploadPart: () => "etag",
376
+ completeUpload: () => {},
377
+ } as const
378
+
379
+ const validResumeState = (overrides: Partial<ResumeState> = {}): ResumeState => ({
380
+ version: 1,
381
+ uploadId: "upload-stored-1",
382
+ chunkSize: 10,
383
+ contentDigestCaptured: false,
384
+ ...overrides,
385
+ })
386
+
387
+ it("throws TypeError when resumeFrom.uploadId is empty string", () => {
388
+ expect(() =>
389
+ uploadMultipartEffect({
390
+ ...baseOptions,
391
+ resumeFrom: validResumeState({ uploadId: "" }),
392
+ })
393
+ ).toThrow(/non-empty string/)
394
+ })
395
+
396
+ it("throws ResumeMismatchError(version_mismatch) when version != 1", () => {
397
+ let caught: unknown
398
+ try {
399
+ uploadMultipartEffect({
400
+ ...baseOptions,
401
+ // Cast: future v2 schema would pass typecheck, but here we force-test the v1 check.
402
+ resumeFrom: { ...validResumeState(), version: 2 as unknown as 1 },
403
+ })
404
+ } catch (e) {
405
+ caught = e
406
+ }
407
+ expect(caught).toBeInstanceOf(ResumeMismatchError)
408
+ expect((caught as ResumeMismatchError).reason).toBe("version_mismatch")
409
+ })
410
+
411
+ it("throws ResumeMismatchError(chunksize_mismatch) when chunkSize differs", () => {
412
+ let caught: unknown
413
+ try {
414
+ uploadMultipartEffect({
415
+ ...baseOptions,
416
+ chunkSize: 10,
417
+ resumeFrom: validResumeState({ chunkSize: 5 }),
418
+ })
419
+ } catch (e) {
420
+ caught = e
421
+ }
422
+ expect(caught).toBeInstanceOf(ResumeMismatchError)
423
+ expect((caught as ResumeMismatchError).reason).toBe("chunksize_mismatch")
424
+ })
425
+
426
+ it("throws ResumeMismatchError(pipeline_mismatch) when pipelineIdentity differs", () => {
427
+ let caught: unknown
428
+ try {
429
+ uploadMultipartEffect({
430
+ ...baseOptions,
431
+ pipelineIdentity: "gzip-v1",
432
+ resumeFrom: validResumeState({ pipelineIdentity: "deflate-v1" }),
433
+ })
434
+ } catch (e) {
435
+ caught = e
436
+ }
437
+ expect(caught).toBeInstanceOf(ResumeMismatchError)
438
+ expect((caught as ResumeMismatchError).reason).toBe("pipeline_mismatch")
439
+ })
440
+
441
+ it("throws ResumeMismatchError(content_mismatch) when contentDigestCaptured=true but contentDigest is undefined (F9)", () => {
442
+ let caught: unknown
443
+ try {
444
+ uploadMultipartEffect({
445
+ ...baseOptions,
446
+ resumeFrom: validResumeState({
447
+ contentDigestCaptured: true,
448
+ contentDigest: undefined,
449
+ }),
450
+ })
451
+ } catch (e) {
452
+ caught = e
453
+ }
454
+ expect(caught).toBeInstanceOf(ResumeMismatchError)
455
+ expect((caught as ResumeMismatchError).reason).toBe("content_mismatch")
456
+ })
457
+
458
+ it.effect("fails with ResumeMismatchError(content_mismatch) at runtime when digest value differs", () =>
459
+ Effect.gen(function* () {
460
+ const exit = yield* Stream.runCollect(
461
+ uploadMultipartEffect({
462
+ stream: new ReadableStream<Uint8Array>({ start: (c) => { c.enqueue(new Uint8Array(10)); c.close() } }),
463
+ chunkSize: 10,
464
+ uploadPart: () => "etag",
465
+ completeUpload: () => {},
466
+ resumeFrom: validResumeState({
467
+ contentDigest: "digest-original",
468
+ contentDigestCaptured: true,
469
+ }),
470
+ getContentDigest: () => "digest-different",
471
+ })
472
+ ).pipe(Effect.exit, Effect.provide(LoggerServiceLive))
473
+
474
+ expect(exit._tag).toBe("Failure")
475
+ const err = Cause.squash((exit as Extract<typeof exit, { _tag: "Failure" }>).cause)
476
+ expect(err).toBeInstanceOf(ResumeMismatchError)
477
+ expect((err as ResumeMismatchError).reason).toBe("content_mismatch")
478
+ })
479
+ )
480
+
481
+ it.effect("accepts a valid resumeFrom: no throw, uploadId honored, reconcile called", () =>
482
+ Effect.gen(function* () {
483
+ let initiateCalled = 0
484
+ let reconcileCalls = 0
485
+ const completedWith: { uploadId: string; parts: ReadonlyArray<CompletedPart> } = { uploadId: "", parts: [] }
486
+
487
+ const events = yield* Stream.runCollect(
488
+ uploadMultipartEffect({
489
+ stream: new ReadableStream<Uint8Array>({ start: (c) => { c.enqueue(new Uint8Array(20)); c.close() } }),
490
+ chunkSize: 10,
491
+ uploadPart: (n) => `fresh-etag-${n}`,
492
+ completeUpload: (uploadId, parts) => {
493
+ completedWith.uploadId = uploadId
494
+ completedWith.parts = parts
495
+ },
496
+ initiate: () => { initiateCalled++; return { uploadId: "should-not-be-used" } },
497
+ reconcileCompletedParts: () => {
498
+ reconcileCalls++
499
+ return [{ partNumber: 1, etag: "etag-reconciled-1" }]
500
+ },
501
+ resumeFrom: validResumeState({ uploadId: "upload-stored-1", chunkSize: 10 }),
502
+ })
503
+ ).pipe(
504
+ Effect.map((chunk) => Array.from(chunk)),
505
+ Effect.provide(LoggerServiceLive)
506
+ )
507
+
508
+ expect(initiateCalled).toBe(0)
509
+ expect(reconcileCalls).toBe(1)
510
+ expect(completedWith.uploadId).toBe("upload-stored-1")
511
+ })
512
+ )
513
+
514
+ it.effect("does NOT emit UploadInitiated on resume (G1)", () =>
515
+ Effect.gen(function* () {
516
+ const events = yield* Stream.runCollect(
517
+ uploadMultipartEffect({
518
+ stream: new ReadableStream<Uint8Array>({ start: (c) => { c.enqueue(new Uint8Array(10)); c.close() } }),
519
+ chunkSize: 10,
520
+ uploadPart: () => "etag-1",
521
+ completeUpload: () => {},
522
+ initiate: () => ({ uploadId: "ignored" }),
523
+ resumeFrom: validResumeState({ uploadId: "upload-stored-1", chunkSize: 10 }),
524
+ })
525
+ ).pipe(
526
+ Effect.map((chunk) => Array.from(chunk)),
527
+ Effect.provide(LoggerServiceLive)
528
+ )
529
+
530
+ expect(events.find((e) => e._tag === "UploadInitiated")).toBeUndefined()
531
+ // First event must come from the parts stream
532
+ expect(events[0]?._tag).toBe("PartCompleted")
533
+ })
534
+ )
336
535
  })