@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.
- package/.turbo/turbo-build.log +68 -68
- package/CHANGELOG.md +67 -0
- package/dist/errors.cjs +3 -1
- package/dist/errors.d.cts +2 -2
- package/dist/errors.d.mts +2 -2
- package/dist/errors.mjs +2 -2
- package/dist/index-BaeUV_fj.d.cts +139 -0
- package/dist/index-BaeUV_fj.d.cts.map +1 -0
- package/dist/index-bpWq6tje.d.mts +139 -0
- package/dist/index-bpWq6tje.d.mts.map +1 -0
- package/dist/multipart.cjs +80 -12
- package/dist/multipart.cjs.map +1 -1
- package/dist/multipart.d.cts +2 -2
- package/dist/multipart.d.mts +2 -2
- package/dist/multipart.mjs +80 -12
- package/dist/multipart.mjs.map +1 -1
- package/dist/{normalize-callback-BNBZZ1jT.cjs → normalize-callback-BdLtk9jb.cjs} +2 -2
- package/dist/{normalize-callback-BNBZZ1jT.cjs.map → normalize-callback-BdLtk9jb.cjs.map} +1 -1
- package/dist/{normalize-callback-DQ6C4gaV.mjs → normalize-callback-tcZ_nyq5.mjs} +2 -2
- package/dist/{normalize-callback-DQ6C4gaV.mjs.map → normalize-callback-tcZ_nyq5.mjs.map} +1 -1
- package/dist/oneshot.cjs +2 -2
- package/dist/oneshot.d.cts +2 -2
- package/dist/oneshot.d.mts +2 -2
- package/dist/oneshot.mjs +2 -2
- package/dist/progress.d.cts +2 -2
- package/dist/progress.d.mts +2 -2
- package/dist/{upload-error-BUexBh08.cjs → upload-error-BG1dOOl3.cjs} +26 -1
- package/dist/upload-error-BG1dOOl3.cjs.map +1 -0
- package/dist/{upload-error-B2ISUc_k.d.cts → upload-error-DTYVNlaJ.d.cts} +19 -3
- package/dist/{upload-error-B2ISUc_k.d.cts.map → upload-error-DTYVNlaJ.d.cts.map} +1 -1
- package/dist/{upload-error-zDvpxT9X.mjs → upload-error-Dbz_9j81.mjs} +21 -2
- package/dist/upload-error-Dbz_9j81.mjs.map +1 -0
- package/dist/{upload-error-jol-eoDW.d.mts → upload-error-jBco270d.d.mts} +19 -3
- package/dist/{upload-error-jol-eoDW.d.mts.map → upload-error-jBco270d.d.mts.map} +1 -1
- package/dist/{upload-event-C9TOVp5l.d.mts → upload-event-BT_nXgM9.d.cts} +7 -1
- package/dist/upload-event-BT_nXgM9.d.cts.map +1 -0
- package/dist/{upload-event-D77olieX.d.cts → upload-event-DOGbegxa.d.mts} +7 -1
- package/dist/upload-event-DOGbegxa.d.mts.map +1 -0
- package/package.json +1 -1
- package/src/errors/index.ts +2 -0
- package/src/errors/upload-error.test.ts +35 -0
- package/src/errors/upload-error.ts +27 -0
- package/src/multipart/index.test.ts +164 -1
- package/src/multipart/index.ts +96 -5
- package/src/multipart/upload-stream.test.ts +201 -2
- package/src/multipart/upload-stream.ts +160 -16
- package/src/progress/upload-event.ts +6 -0
- package/dist/index-Ch8xM6Xt.d.cts +0 -60
- package/dist/index-Ch8xM6Xt.d.cts.map +0 -1
- package/dist/index-DBGtgXEd.d.mts +0 -60
- package/dist/index-DBGtgXEd.d.mts.map +0 -1
- package/dist/upload-error-BUexBh08.cjs.map +0 -1
- package/dist/upload-error-zDvpxT9X.mjs.map +0 -1
- package/dist/upload-event-C9TOVp5l.d.mts.map +0 -1
- 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
|
+
})
|
package/src/multipart/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
})
|