@tranquilload/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.turbo/turbo-build.log +88 -0
  2. package/dist/compression-service-Bm1VBnhT.mjs +18 -0
  3. package/dist/compression-service-Bm1VBnhT.mjs.map +1 -0
  4. package/dist/compression-service-Bn86iTJe.cjs +35 -0
  5. package/dist/compression-service-Bn86iTJe.cjs.map +1 -0
  6. package/dist/compression-service-CiF7Px08.d.cts +15 -0
  7. package/dist/compression-service-CiF7Px08.d.cts.map +1 -0
  8. package/dist/compression-service-DI7ZXVxH.d.mts +15 -0
  9. package/dist/compression-service-DI7ZXVxH.d.mts.map +1 -0
  10. package/dist/errors.cjs +9 -0
  11. package/dist/errors.d.cts +2 -0
  12. package/dist/errors.d.mts +2 -0
  13. package/dist/errors.mjs +2 -0
  14. package/dist/index-Ch8xM6Xt.d.cts +60 -0
  15. package/dist/index-Ch8xM6Xt.d.cts.map +1 -0
  16. package/dist/index-DBGtgXEd.d.mts +60 -0
  17. package/dist/index-DBGtgXEd.d.mts.map +1 -0
  18. package/dist/logger-service-1J5r_akj.mjs +8 -0
  19. package/dist/logger-service-1J5r_akj.mjs.map +1 -0
  20. package/dist/logger-service-BF2pZOHN.d.mts +12 -0
  21. package/dist/logger-service-BF2pZOHN.d.mts.map +1 -0
  22. package/dist/logger-service-CbN12RhO.d.cts +12 -0
  23. package/dist/logger-service-CbN12RhO.d.cts.map +1 -0
  24. package/dist/logger-service-cx8vzkXs.cjs +19 -0
  25. package/dist/logger-service-cx8vzkXs.cjs.map +1 -0
  26. package/dist/middleware-CAI0cnW2.d.mts +10 -0
  27. package/dist/middleware-CAI0cnW2.d.mts.map +1 -0
  28. package/dist/middleware-CYcctmlY.d.cts +10 -0
  29. package/dist/middleware-CYcctmlY.d.cts.map +1 -0
  30. package/dist/multipart.cjs +244 -0
  31. package/dist/multipart.cjs.map +1 -0
  32. package/dist/multipart.d.cts +2 -0
  33. package/dist/multipart.d.mts +2 -0
  34. package/dist/multipart.mjs +243 -0
  35. package/dist/multipart.mjs.map +1 -0
  36. package/dist/normalize-callback-BNBZZ1jT.cjs +44 -0
  37. package/dist/normalize-callback-BNBZZ1jT.cjs.map +1 -0
  38. package/dist/normalize-callback-DQ6C4gaV.mjs +33 -0
  39. package/dist/normalize-callback-DQ6C4gaV.mjs.map +1 -0
  40. package/dist/oneshot.cjs +64 -0
  41. package/dist/oneshot.cjs.map +1 -0
  42. package/dist/oneshot.d.cts +28 -0
  43. package/dist/oneshot.d.cts.map +1 -0
  44. package/dist/oneshot.d.mts +28 -0
  45. package/dist/oneshot.d.mts.map +1 -0
  46. package/dist/oneshot.mjs +63 -0
  47. package/dist/oneshot.mjs.map +1 -0
  48. package/dist/pipeline.cjs +16 -0
  49. package/dist/pipeline.cjs.map +1 -0
  50. package/dist/pipeline.d.cts +9 -0
  51. package/dist/pipeline.d.cts.map +1 -0
  52. package/dist/pipeline.d.mts +9 -0
  53. package/dist/pipeline.d.mts.map +1 -0
  54. package/dist/pipeline.mjs +14 -0
  55. package/dist/pipeline.mjs.map +1 -0
  56. package/dist/progress.cjs +0 -0
  57. package/dist/progress.d.cts +3 -0
  58. package/dist/progress.d.mts +3 -0
  59. package/dist/progress.mjs +1 -0
  60. package/dist/services.cjs +8 -0
  61. package/dist/services.d.cts +3 -0
  62. package/dist/services.d.mts +3 -0
  63. package/dist/services.mjs +3 -0
  64. package/dist/upload-error-B2ISUc_k.d.cts +48 -0
  65. package/dist/upload-error-B2ISUc_k.d.cts.map +1 -0
  66. package/dist/upload-error-BUexBh08.cjs +119 -0
  67. package/dist/upload-error-BUexBh08.cjs.map +1 -0
  68. package/dist/upload-error-jol-eoDW.d.mts +48 -0
  69. package/dist/upload-error-jol-eoDW.d.mts.map +1 -0
  70. package/dist/upload-error-zDvpxT9X.mjs +72 -0
  71. package/dist/upload-error-zDvpxT9X.mjs.map +1 -0
  72. package/dist/upload-event-C9TOVp5l.d.mts +36 -0
  73. package/dist/upload-event-C9TOVp5l.d.mts.map +1 -0
  74. package/dist/upload-event-D77olieX.d.cts +36 -0
  75. package/dist/upload-event-D77olieX.d.cts.map +1 -0
  76. package/package.json +70 -0
  77. package/src/errors/index.ts +10 -0
  78. package/src/errors/upload-error.test.ts +218 -0
  79. package/src/errors/upload-error.ts +89 -0
  80. package/src/multipart/chunk-stream.test.ts +79 -0
  81. package/src/multipart/chunk-stream.ts +37 -0
  82. package/src/multipart/circuit-breaker.test.ts +95 -0
  83. package/src/multipart/circuit-breaker.ts +68 -0
  84. package/src/multipart/index.test.ts +283 -0
  85. package/src/multipart/index.ts +119 -0
  86. package/src/multipart/upload-stream.test.ts +336 -0
  87. package/src/multipart/upload-stream.ts +246 -0
  88. package/src/oneshot/index.test.ts +153 -0
  89. package/src/oneshot/index.ts +76 -0
  90. package/src/oneshot/upload.test.ts +130 -0
  91. package/src/oneshot/upload.ts +52 -0
  92. package/src/pipeline/compress.test.ts +69 -0
  93. package/src/pipeline/compress.ts +8 -0
  94. package/src/pipeline/index.ts +3 -0
  95. package/src/pipeline/middleware.test.ts +102 -0
  96. package/src/pipeline/middleware.ts +30 -0
  97. package/src/progress/getprogress.test.ts +102 -0
  98. package/src/progress/index.ts +10 -0
  99. package/src/progress/upload-event.test.ts +102 -0
  100. package/src/progress/upload-event.ts +37 -0
  101. package/src/scaffold.test.ts +5 -0
  102. package/src/services/compression-service.test.ts +68 -0
  103. package/src/services/compression-service.ts +31 -0
  104. package/src/services/index.ts +11 -0
  105. package/src/services/logger-service-integration.test.ts +98 -0
  106. package/src/services/logger-service.test.ts +40 -0
  107. package/src/services/logger-service.ts +17 -0
  108. package/src/utils/abort-interop.test.ts +65 -0
  109. package/src/utils/abort-interop.ts +14 -0
  110. package/src/utils/normalize-callback.test.ts +46 -0
  111. package/src/utils/normalize-callback.ts +18 -0
  112. package/tsconfig.json +8 -0
  113. package/tsdown.config.ts +16 -0
  114. package/vitest.config.ts +7 -0
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Option } from "effect"
3
+ import { uploadMultipart, type Progress } from "../multipart/index.js"
4
+
5
+ // Helper: create a ReadableStream from repeated chunks with a delay
6
+ const slowStream = (chunkCount: number, chunkSize: number): ReadableStream<Uint8Array> =>
7
+ new ReadableStream({
8
+ async start(controller) {
9
+ for (let i = 0; i < chunkCount; i++) {
10
+ controller.enqueue(new Uint8Array(chunkSize).fill(i))
11
+ await new Promise((r) => setTimeout(r, 5))
12
+ }
13
+ controller.close()
14
+ },
15
+ })
16
+
17
+ describe("getProgress()", () => {
18
+ it.effect("getProgress() returns increasing bytesUploaded during an in-progress upload", () =>
19
+ Effect.gen(function* () {
20
+ let snapshotDuringUpload: Progress | null = null
21
+
22
+ const { result, getProgress } = uploadMultipart({
23
+ stream: slowStream(3, 10), // 3 parts × 10 bytes = 30 bytes total
24
+ chunkSize: 10,
25
+ uploadPart: async (partNumber, _chunk) => {
26
+ // Poll getProgress mid-upload (while part 2 is uploading — part 1 already completed)
27
+ if (partNumber === 2) {
28
+ snapshotDuringUpload = await getProgress()
29
+ }
30
+ return `etag-${partNumber}`
31
+ },
32
+ completeUpload: () => {},
33
+ })
34
+
35
+ yield* Effect.promise(() => result)
36
+
37
+ // snapshot taken while part 2 is uploading → part 1 completed → bytesUploaded ≥ 10
38
+ expect(snapshotDuringUpload).not.toBeNull()
39
+ expect((snapshotDuringUpload as unknown as Progress).bytesUploaded).toBeGreaterThanOrEqual(10)
40
+
41
+ // After completion, full 30 bytes accounted
42
+ const finalProgress = yield* Effect.promise(() => getProgress())
43
+ expect(finalProgress.bytesUploaded).toBe(30)
44
+ })
45
+ )
46
+
47
+ it.effect("calling getProgress() multiple times does not affect the upload", () =>
48
+ Effect.gen(function* () {
49
+ const { result, getProgress } = uploadMultipart({
50
+ stream: new ReadableStream({
51
+ start(c) {
52
+ c.enqueue(new Uint8Array(20).fill(1))
53
+ c.close()
54
+ },
55
+ }),
56
+ chunkSize: 10,
57
+ uploadPart: () => "etag",
58
+ completeUpload: () => {},
59
+ })
60
+
61
+ yield* Effect.promise(() => result)
62
+
63
+ const p1 = yield* Effect.promise(() => getProgress())
64
+ const p2 = yield* Effect.promise(() => getProgress())
65
+ const p3 = yield* Effect.promise(() => getProgress())
66
+
67
+ expect(p1.bytesUploaded).toBe(20)
68
+ expect(p2.bytesUploaded).toBe(20)
69
+ expect(p3.bytesUploaded).toBe(20)
70
+ expect(p1.totalBytes).toEqual(Option.none())
71
+ })
72
+ )
73
+
74
+ it.effect("getProgress.effect reads from Ref without launching the upload", () =>
75
+ Effect.gen(function* () {
76
+ const { result, getProgress } = uploadMultipart({
77
+ stream: new ReadableStream({
78
+ start(c) {
79
+ c.enqueue(new Uint8Array(15).fill(1))
80
+ c.close()
81
+ },
82
+ }),
83
+ chunkSize: 15,
84
+ uploadPart: () => "etag",
85
+ completeUpload: () => {},
86
+ totalBytes: 15,
87
+ })
88
+
89
+ // Call getProgress.effect BEFORE awaiting result — proves it doesn't launch the upload
90
+ const before = yield* getProgress.effect
91
+ expect(before.bytesUploaded).toBe(0)
92
+ expect(before.totalBytes).toEqual(Option.some(15))
93
+
94
+ yield* Effect.promise(() => result)
95
+
96
+ // After completion, Ref reflects final state
97
+ const after = yield* getProgress.effect
98
+ expect(after.bytesUploaded).toBe(15)
99
+ expect(after.totalBytes).toEqual(Option.some(15))
100
+ })
101
+ )
102
+ })
@@ -0,0 +1,10 @@
1
+ export type {
2
+ UploadEvent,
3
+ UploadInitiated,
4
+ PartCompleted,
5
+ ProgressTick,
6
+ UploadCompleted,
7
+ CircuitOpen,
8
+ } from "./upload-event.js"
9
+
10
+ export type { Progress } from "../multipart/index.js"
@@ -0,0 +1,102 @@
1
+ import { it, describe, expect } from "@effect/vitest"
2
+ import { Effect, Match, Option } from "effect"
3
+ import type { UploadEvent, ProgressTick } from "./upload-event.js"
4
+ import { uploadMultipart } from "../multipart/index.js"
5
+
6
+ describe("UploadEvent type system", () => {
7
+ it("exhaustive switch on _tag compiles and handles all variants", () => {
8
+ const handle = (event: UploadEvent): string => {
9
+ switch (event._tag) {
10
+ case "UploadInitiated":
11
+ return "initiated"
12
+ case "PartCompleted":
13
+ return "part"
14
+ case "ProgressTick":
15
+ return "progress"
16
+ case "UploadCompleted":
17
+ return "done"
18
+ case "CircuitOpen":
19
+ return "circuit"
20
+ }
21
+ }
22
+ const event: UploadEvent = {
23
+ _tag: "ProgressTick",
24
+ bytesUploaded: 500,
25
+ totalBytes: Option.none(),
26
+ timestamp: 0,
27
+ }
28
+ expect(handle(event)).toBe("progress")
29
+ })
30
+
31
+ it.effect("Match.tag handles all variants exhaustively", () =>
32
+ Effect.gen(function* () {
33
+ const event: UploadEvent = {
34
+ _tag: "PartCompleted",
35
+ partNumber: 1,
36
+ etag: "abc",
37
+ bytesUploaded: 100,
38
+ timestamp: 0,
39
+ }
40
+ const result = Match.type<UploadEvent>().pipe(
41
+ Match.tag("UploadInitiated", (e) => `initiated:${e.uploadId}`),
42
+ Match.tag("PartCompleted", (e) => `part:${e.partNumber}`),
43
+ Match.tag("ProgressTick", (e) => `progress:${e.bytesUploaded}`),
44
+ Match.tag("UploadCompleted", (e) => `done:${e.totalParts}`),
45
+ Match.tag("CircuitOpen", (e) => `circuit:${e.failedParts}`),
46
+ Match.exhaustive
47
+ )(event)
48
+ expect(result).toBe("part:1")
49
+ })
50
+ )
51
+
52
+ it("all variants have _tag and timestamp fields", () => {
53
+ const variants: UploadEvent[] = [
54
+ { _tag: "UploadInitiated", uploadId: "id", timestamp: 0 },
55
+ { _tag: "PartCompleted", partNumber: 1, etag: "e", bytesUploaded: 10, timestamp: 1 },
56
+ { _tag: "ProgressTick", bytesUploaded: 10, totalBytes: Option.some(100), timestamp: 2 },
57
+ { _tag: "UploadCompleted", uploadId: "id", totalParts: 1, timestamp: 3 },
58
+ { _tag: "CircuitOpen", failedParts: 3, timestamp: 4 },
59
+ ]
60
+ for (const v of variants) {
61
+ expect(typeof v._tag).toBe("string")
62
+ expect(typeof v.timestamp).toBe("number")
63
+ }
64
+ })
65
+
66
+ it.effect("uploadMultipart emits ProgressTick after each PartCompleted", () =>
67
+ Effect.gen(function* () {
68
+ const allEvents: UploadEvent[] = []
69
+ const { result, events } = uploadMultipart({
70
+ stream: new ReadableStream({
71
+ start(c) {
72
+ c.enqueue(new Uint8Array([1, 2, 3]))
73
+ c.enqueue(new Uint8Array([4, 5, 6]))
74
+ c.close()
75
+ },
76
+ }),
77
+ chunkSize: 3,
78
+ uploadPart: (_partNumber, _chunk) => "etag",
79
+ completeUpload: () => {},
80
+ })
81
+
82
+ const consumeEvents = async () => {
83
+ const reader = events.getReader()
84
+ while (true) {
85
+ const { done, value } = await reader.read()
86
+ if (done) break
87
+ allEvents.push(value)
88
+ }
89
+ }
90
+
91
+ yield* Effect.promise(() => Promise.all([result, consumeEvents()]))
92
+ const progressTicks = allEvents.filter((e) => e._tag === "ProgressTick")
93
+ expect(progressTicks).toHaveLength(2)
94
+ const tick1 = progressTicks[0] as ProgressTick
95
+ const tick2 = progressTicks[1] as ProgressTick
96
+ expect(tick1.bytesUploaded).toBe(3)
97
+ expect(tick1.totalBytes).toStrictEqual(Option.none())
98
+ expect(tick2.bytesUploaded).toBe(6)
99
+ expect(tick2.totalBytes).toStrictEqual(Option.none())
100
+ })
101
+ )
102
+ })
@@ -0,0 +1,37 @@
1
+ import { Option } from "effect"
2
+
3
+ export interface UploadInitiated {
4
+ readonly _tag: "UploadInitiated"
5
+ readonly uploadId: string
6
+ readonly timestamp: number
7
+ }
8
+
9
+ export interface UploadCompleted {
10
+ readonly _tag: "UploadCompleted"
11
+ readonly uploadId: string
12
+ readonly totalParts: number
13
+ readonly timestamp: number
14
+ }
15
+
16
+ export interface PartCompleted {
17
+ readonly _tag: "PartCompleted"
18
+ readonly partNumber: number
19
+ readonly etag: string
20
+ readonly bytesUploaded: number
21
+ readonly timestamp: number
22
+ }
23
+
24
+ export interface CircuitOpen {
25
+ readonly _tag: "CircuitOpen"
26
+ readonly failedParts: number
27
+ readonly timestamp: number
28
+ }
29
+
30
+ export interface ProgressTick {
31
+ readonly _tag: "ProgressTick"
32
+ readonly bytesUploaded: number
33
+ readonly totalBytes: Option.Option<number>
34
+ readonly timestamp: number
35
+ }
36
+
37
+ export type UploadEvent = UploadInitiated | UploadCompleted | PartCompleted | ProgressTick | CircuitOpen
@@ -0,0 +1,5 @@
1
+ import { it } from 'vitest'
2
+
3
+ it('scaffold', () => {
4
+ // Placeholder test — replaced when business logic is implemented
5
+ })
@@ -0,0 +1,68 @@
1
+ import { it, describe, expect } from "@effect/vitest"
2
+ import { Cause, Effect, Layer } from "effect"
3
+ import {
4
+ CompressionService,
5
+ CompressionServiceLive,
6
+ CompressionUnavailableError,
7
+ } from "./compression-service.js"
8
+
9
+ describe("CompressionService", () => {
10
+ it.effect("CompressionServiceLive fails with typed CompressionUnavailableError when CompressionStream is absent", () =>
11
+ Effect.gen(function* () {
12
+ const AbsentLayer: Layer.Layer<CompressionService, CompressionUnavailableError> =
13
+ Layer.effect(CompressionService, Effect.fail(new CompressionUnavailableError()))
14
+
15
+ const result = yield* Effect.exit(
16
+ Effect.provide(
17
+ Effect.flatMap(CompressionService, (s) => Effect.succeed(s)),
18
+ AbsentLayer
19
+ )
20
+ )
21
+
22
+ expect(result._tag).toBe("Failure")
23
+ if (result._tag === "Failure") {
24
+ const failure = Cause.failureOption(result.cause)
25
+ expect(failure._tag).toBe("Some")
26
+ if (failure._tag === "Some") {
27
+ expect(failure.value).toBeInstanceOf(CompressionUnavailableError)
28
+ expect(failure.value._tag).toBe("CompressionUnavailableError")
29
+ expect(failure.value.message).toBe("globalThis.CompressionStream is not available in this environment")
30
+ }
31
+ }
32
+ })
33
+ )
34
+
35
+ it.effect("CompressionUnavailableError has correct _tag and message", () =>
36
+ Effect.gen(function* () {
37
+ const err = new CompressionUnavailableError()
38
+ expect(err._tag).toBe("CompressionUnavailableError")
39
+ expect(err.name).toBe("CompressionUnavailableError")
40
+ expect(err.message).toBe("globalThis.CompressionStream is not available in this environment")
41
+ })
42
+ )
43
+
44
+ it.effect("custom CompressionService Layer is used when provided", () =>
45
+ Effect.gen(function* () {
46
+ const mockStream = new ReadableStream<Uint8Array>()
47
+ let calledWith: ReadableStream<Uint8Array> | null = null
48
+
49
+ const TestLayer: Layer.Layer<CompressionService> = Layer.succeed(CompressionService, {
50
+ compress: (stream: ReadableStream<Uint8Array>, _algorithm: CompressionFormat): ReadableStream<Uint8Array> => {
51
+ calledWith = stream
52
+ return mockStream
53
+ },
54
+ })
55
+
56
+ const inputStream = new ReadableStream<Uint8Array>()
57
+ const result = yield* Effect.provide(
58
+ Effect.flatMap(CompressionService, (svc) =>
59
+ Effect.sync(() => svc.compress(inputStream, "deflate-raw"))
60
+ ),
61
+ TestLayer
62
+ )
63
+
64
+ expect(result).toBe(mockStream)
65
+ expect(calledWith).toBe(inputStream)
66
+ })
67
+ )
68
+ })
@@ -0,0 +1,31 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+
3
+ export class CompressionUnavailableError extends Error {
4
+ readonly _tag = "CompressionUnavailableError" as const
5
+ constructor() {
6
+ super("globalThis.CompressionStream is not available in this environment")
7
+ this.name = "CompressionUnavailableError"
8
+ }
9
+ }
10
+
11
+ export class CompressionService extends Context.Tag("@tranquilload/CompressionService")<
12
+ CompressionService,
13
+ { readonly compress: (stream: ReadableStream<Uint8Array>, algorithm: CompressionFormat) => ReadableStream<Uint8Array> }
14
+ >() {}
15
+
16
+ export const CompressionServiceLive: Layer.Layer<CompressionService, CompressionUnavailableError> =
17
+ Layer.effect(
18
+ CompressionService,
19
+ Effect.gen(function* () {
20
+ const cs = (globalThis as { CompressionStream?: unknown }).CompressionStream
21
+ if (typeof cs === "undefined") {
22
+ return yield* Effect.fail(new CompressionUnavailableError())
23
+ }
24
+ return {
25
+ compress: (stream, algorithm) =>
26
+ stream.pipeThrough(
27
+ new CompressionStream(algorithm) as unknown as TransformStream<Uint8Array, Uint8Array>
28
+ ),
29
+ }
30
+ })
31
+ )
@@ -0,0 +1,11 @@
1
+ export {
2
+ CompressionUnavailableError,
3
+ CompressionService,
4
+ CompressionServiceLive,
5
+ } from "./compression-service.js"
6
+
7
+ export {
8
+ type LogLevel,
9
+ LoggerService,
10
+ LoggerServiceLive,
11
+ } from "./logger-service.js"
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Effect, Layer, Stream } from "effect"
3
+ import { LoggerService, type LogLevel } from "./logger-service.js"
4
+ import { uploadOnce } from "../oneshot/index.js"
5
+ import { uploadMultipart } from "../multipart/index.js"
6
+ import { uploadOnceEffect } from "../oneshot/upload.js"
7
+ import { uploadMultipartEffect } from "../multipart/upload-stream.js"
8
+
9
+ // Helpers
10
+ const tinyStream = (bytes: number): ReadableStream<Uint8Array> =>
11
+ new ReadableStream({
12
+ start(c) {
13
+ c.enqueue(new Uint8Array(bytes).fill(1))
14
+ c.close()
15
+ },
16
+ })
17
+
18
+ type LogEntry = { level: LogLevel; message: string; data?: unknown }
19
+
20
+ const makeTestLayer = (received: LogEntry[]): Layer.Layer<LoggerService> =>
21
+ Layer.succeed(LoggerService, {
22
+ log: (level, message, data?) => {
23
+ received.push({ level, message, data })
24
+ },
25
+ })
26
+
27
+ describe("LoggerService integration", () => {
28
+ it.effect("uploadOnce.effect with custom LoggerService captures internal log entries", () =>
29
+ Effect.gen(function* () {
30
+ const received: LogEntry[] = []
31
+
32
+ yield* Stream.runDrain(
33
+ uploadOnceEffect({
34
+ stream: tinyStream(10),
35
+ upload: () => {},
36
+ }).pipe(Stream.provideLayer(makeTestLayer(received)))
37
+ )
38
+
39
+ // Expect "One-shot upload starting" and "One-shot upload completed"
40
+ expect(received).toHaveLength(2)
41
+ expect(received.some(e => e.message === "One-shot upload starting")).toBe(true)
42
+ expect(received.some(e => e.message === "One-shot upload completed")).toBe(true)
43
+ expect(received.every(e => e.level === "info")).toBe(true)
44
+ })
45
+ )
46
+
47
+ it.effect("uploadMultipart.effect with custom LoggerService captures part completion and final log", () =>
48
+ Effect.gen(function* () {
49
+ const received: LogEntry[] = []
50
+
51
+ yield* Stream.runDrain(
52
+ uploadMultipartEffect({
53
+ stream: tinyStream(20),
54
+ chunkSize: 10,
55
+ uploadPart: (_partNumber, _chunk) => "etag",
56
+ completeUpload: () => {},
57
+ }).pipe(Stream.provideLayer(makeTestLayer(received)))
58
+ )
59
+
60
+ // 2 parts → 2 "Part N completed" + 1 "Multipart upload completed"
61
+ const partLogs = received.filter(e => e.message.startsWith("Part ") && e.message.endsWith("completed"))
62
+ expect(partLogs.length).toBe(2)
63
+ expect(received.some(e => e.message === "Multipart upload completed")).toBe(true)
64
+ })
65
+ )
66
+
67
+ it.effect("Promise API (uploadOnce) auto-provides LoggerServiceLive — user log fn is never called", () =>
68
+ Effect.gen(function* () {
69
+ const received: LogEntry[] = []
70
+
71
+ // uploadOnce uses LoggerServiceLive (no-op) — custom logger receives nothing
72
+ const { result } = uploadOnce({
73
+ stream: tinyStream(10),
74
+ upload: () => {},
75
+ })
76
+ yield* Effect.promise(() => result)
77
+
78
+ // The custom logger was never invoked — Promise API is fully wired
79
+ expect(received).toHaveLength(0)
80
+ })
81
+ )
82
+
83
+ it.effect("Promise API (uploadMultipart) auto-provides LoggerServiceLive — user log fn is never called", () =>
84
+ Effect.gen(function* () {
85
+ const received: LogEntry[] = []
86
+
87
+ const { result } = uploadMultipart({
88
+ stream: tinyStream(20),
89
+ chunkSize: 10,
90
+ uploadPart: () => "etag",
91
+ completeUpload: () => {},
92
+ })
93
+ yield* Effect.promise(() => result)
94
+
95
+ expect(received).toHaveLength(0)
96
+ })
97
+ )
98
+ })
@@ -0,0 +1,40 @@
1
+ import { it, describe, expect } from "@effect/vitest"
2
+ import { Effect, Layer } from "effect"
3
+ import { LoggerService, LoggerServiceLive, type LogLevel } from "./logger-service.js"
4
+
5
+ describe("LoggerService", () => {
6
+ it.effect("LoggerServiceLive is a no-op (produces zero output)", () =>
7
+ Effect.gen(function* () {
8
+ const logger = yield* LoggerService
9
+ expect(() => logger.log("info", "test message", { key: "value" })).not.toThrow()
10
+ expect(() => logger.log("debug", "debug msg")).not.toThrow()
11
+ expect(() => logger.log("warn", "warn msg")).not.toThrow()
12
+ expect(() => logger.log("error", "error msg")).not.toThrow()
13
+ }).pipe(Effect.provide(LoggerServiceLive))
14
+ )
15
+
16
+ it.effect("custom LoggerService Layer receives structured log entries", () =>
17
+ Effect.gen(function* () {
18
+ const received: Array<{ level: LogLevel; message: string; data?: unknown }> = []
19
+
20
+ const TestLayer: Layer.Layer<LoggerService> = Layer.succeed(LoggerService, {
21
+ log: (level: LogLevel, message: string, data?: unknown): void => {
22
+ received.push({ level, message, data })
23
+ },
24
+ })
25
+
26
+ yield* Effect.provide(
27
+ Effect.gen(function* () {
28
+ const logger = yield* LoggerService
29
+ logger.log("info", "part completed", { partNumber: 1 })
30
+ logger.log("warn", "retry attempt", { attempt: 2 })
31
+ }),
32
+ TestLayer
33
+ )
34
+
35
+ expect(received).toHaveLength(2)
36
+ expect(received[0]).toEqual({ level: "info", message: "part completed", data: { partNumber: 1 } })
37
+ expect(received[1]).toEqual({ level: "warn", message: "retry attempt", data: { attempt: 2 } })
38
+ })
39
+ )
40
+ })
@@ -0,0 +1,17 @@
1
+ import { Context, Layer } from "effect"
2
+
3
+ export type LogLevel = "debug" | "info" | "warn" | "error"
4
+
5
+ export class LoggerService extends Context.Tag("@tranquilload/LoggerService")<
6
+ LoggerService,
7
+ { readonly log: (level: LogLevel, message: string, data?: unknown) => void }
8
+ >() {}
9
+
10
+ export const LoggerServiceLive: Layer.Layer<LoggerService> = Layer.succeed(
11
+ LoggerService,
12
+ {
13
+ log: (_level: LogLevel, _message: string, _data?: unknown): void => {
14
+ // intentional no-op
15
+ },
16
+ }
17
+ )
@@ -0,0 +1,65 @@
1
+ import { Effect, Exit, Fiber } from "effect"
2
+ import { it, describe, expect } from "@effect/vitest"
3
+ import { fromAbortSignal } from "./abort-interop.js"
4
+ import { AbortError } from "../errors/upload-error.js"
5
+
6
+ describe("fromAbortSignal", () => {
7
+ it.effect("no signal: never settles on its own (race wins with other branch)", () =>
8
+ Effect.gen(function* () {
9
+ // fromAbortSignal with no signal never resolves; race with a succeeding Effect
10
+ const result = yield* Effect.race(Effect.succeed("winner"), fromAbortSignal())
11
+ expect(result).toBe("winner")
12
+ })
13
+ )
14
+
15
+ it.effect("signal already aborted: fails immediately with AbortError", () =>
16
+ Effect.gen(function* () {
17
+ const controller = new AbortController()
18
+ controller.abort()
19
+ const exit = yield* Effect.exit(fromAbortSignal(controller.signal))
20
+ expect(Exit.isFailure(exit)).toBe(true)
21
+ if (Exit.isFailure(exit)) {
22
+ const cause = exit.cause
23
+ // The error should be an AbortError
24
+ expect(cause._tag).toBe("Fail")
25
+ if (cause._tag === "Fail") {
26
+ expect(cause.error).toBeInstanceOf(AbortError)
27
+ expect((cause.error as AbortError)._tag).toBe("AbortError")
28
+ }
29
+ }
30
+ })
31
+ )
32
+
33
+ it.effect("controller.abort() after fromAbortSignal: fails with AbortError", () =>
34
+ Effect.gen(function* () {
35
+ const controller = new AbortController()
36
+ const fiber = yield* Effect.fork(fromAbortSignal(controller.signal))
37
+ yield* Effect.sync(() => controller.abort())
38
+ const exit = yield* Fiber.await(fiber)
39
+ expect(Exit.isFailure(exit)).toBe(true)
40
+ if (Exit.isFailure(exit)) {
41
+ const cause = exit.cause
42
+ expect(cause._tag).toBe("Fail")
43
+ if (cause._tag === "Fail") {
44
+ expect(cause.error).toBeInstanceOf(AbortError)
45
+ }
46
+ }
47
+ })
48
+ )
49
+
50
+ it.effect("AbortError shape: _tag, instanceof Error, message", () =>
51
+ Effect.gen(function* () {
52
+ const controller = new AbortController()
53
+ controller.abort()
54
+ const exit = yield* Effect.exit(fromAbortSignal(controller.signal))
55
+ if (Exit.isFailure(exit) && exit.cause._tag === "Fail") {
56
+ const error = exit.cause.error as AbortError
57
+ expect(error._tag).toBe("AbortError")
58
+ expect(error).toBeInstanceOf(Error)
59
+ expect(error.message).toBe("Upload aborted")
60
+ } else {
61
+ expect.fail("Expected a Fail exit")
62
+ }
63
+ })
64
+ )
65
+ })
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect"
2
+ import { AbortError } from "../errors/upload-error.js"
3
+
4
+ export const fromAbortSignal = (signal?: AbortSignal): Effect.Effect<never, AbortError> =>
5
+ Effect.async<never, AbortError>((resume) => {
6
+ if (!signal) return
7
+ if (signal.aborted) {
8
+ resume(Effect.fail(new AbortError()))
9
+ return
10
+ }
11
+ const handler = (): void => resume(Effect.fail(new AbortError()))
12
+ signal.addEventListener("abort", handler, { once: true })
13
+ return Effect.sync(() => signal.removeEventListener("abort", handler))
14
+ })
@@ -0,0 +1,46 @@
1
+ import { Effect, Exit } from "effect"
2
+ import { it, describe, expect } from "@effect/vitest"
3
+ import { normalizeCallback } from "./normalize-callback.js"
4
+
5
+ describe("normalizeCallback", () => {
6
+ it.effect("plain value: succeeds with the value", () =>
7
+ Effect.gen(function* () {
8
+ const result = yield* normalizeCallback(() => 42)
9
+ expect(result).toBe(42)
10
+ })
11
+ )
12
+
13
+ it.effect("Promise: succeeds with resolved value", () =>
14
+ Effect.gen(function* () {
15
+ const result = yield* normalizeCallback(() => Promise.resolve("hello"))
16
+ expect(result).toBe("hello")
17
+ })
18
+ )
19
+
20
+ it.effect("Effect: passes through unchanged", () =>
21
+ Effect.gen(function* () {
22
+ const result = yield* normalizeCallback(() => Effect.succeed(true))
23
+ expect(result).toBe(true)
24
+ })
25
+ )
26
+
27
+ it.effect("throwing function: fails in error channel (not unhandled)", () =>
28
+ Effect.gen(function* () {
29
+ const exit = yield* Effect.exit(
30
+ normalizeCallback(() => {
31
+ throw new Error("boom")
32
+ })
33
+ )
34
+ expect(Exit.isFailure(exit)).toBe(true)
35
+ })
36
+ )
37
+
38
+ it.effect("Promise rejection: fails in error channel", () =>
39
+ Effect.gen(function* () {
40
+ const exit = yield* Effect.exit(
41
+ normalizeCallback(() => Promise.reject(new Error("async fail")))
42
+ )
43
+ expect(Exit.isFailure(exit)).toBe(true)
44
+ })
45
+ )
46
+ })
@@ -0,0 +1,18 @@
1
+ import { Effect } from "effect"
2
+
3
+ export const normalizeCallback = <A, E = never>(
4
+ fn: () => A | Promise<A> | Effect.Effect<A, E>
5
+ ): Effect.Effect<A, E | unknown> =>
6
+ Effect.suspend((): Effect.Effect<A, E | unknown> => {
7
+ let result: A | Promise<A> | Effect.Effect<A, E>
8
+ try {
9
+ result = fn()
10
+ } catch (e) {
11
+ return Effect.fail(e)
12
+ }
13
+ if (Effect.isEffect(result)) return result
14
+ if (result instanceof Promise) {
15
+ return Effect.tryPromise({ try: () => result as Promise<A>, catch: (e) => e })
16
+ }
17
+ return Effect.succeed(result)
18
+ })