@smthrs/artifacts 0.0.0-stage → 1.0.0-rc.3

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 (171) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +173 -2
  4. package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
  5. package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
  6. package/dist/cjs/ArtifactBackupLease.js +175 -0
  7. package/dist/cjs/ArtifactBackupLease.js.map +7 -0
  8. package/dist/cjs/ArtifactStore.d.ts +129 -0
  9. package/dist/cjs/ArtifactStore.d.ts.map +1 -0
  10. package/dist/cjs/ArtifactStore.js +73 -0
  11. package/dist/cjs/ArtifactStore.js.map +7 -0
  12. package/dist/cjs/ArtifactStoreError.d.ts +82 -0
  13. package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
  14. package/dist/cjs/ArtifactStoreError.js +72 -0
  15. package/dist/cjs/ArtifactStoreError.js.map +7 -0
  16. package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
  17. package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
  18. package/dist/cjs/ArtifactStoreMetrics.js +51 -0
  19. package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
  20. package/dist/cjs/ArtifactSweep.d.ts +185 -0
  21. package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
  22. package/dist/cjs/ArtifactSweep.js +163 -0
  23. package/dist/cjs/ArtifactSweep.js.map +7 -0
  24. package/dist/cjs/CombinedArtifacts.d.ts +103 -0
  25. package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
  26. package/dist/cjs/CombinedArtifacts.js +189 -0
  27. package/dist/cjs/CombinedArtifacts.js.map +7 -0
  28. package/dist/cjs/FileLease.d.ts +113 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +201 -0
  31. package/dist/cjs/FileLease.js.map +7 -0
  32. package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
  33. package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
  34. package/dist/cjs/FileSystemArtifactStore.js +276 -0
  35. package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
  36. package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
  37. package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
  38. package/dist/cjs/MemoryArtifactStore.js +84 -0
  39. package/dist/cjs/MemoryArtifactStore.js.map +7 -0
  40. package/dist/cjs/NoopArtifactStore.d.ts +17 -0
  41. package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
  42. package/dist/cjs/NoopArtifactStore.js +46 -0
  43. package/dist/cjs/NoopArtifactStore.js.map +7 -0
  44. package/dist/cjs/RemoteArtifacts.d.ts +237 -0
  45. package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
  46. package/dist/cjs/RemoteArtifacts.js +405 -0
  47. package/dist/cjs/RemoteArtifacts.js.map +7 -0
  48. package/dist/cjs/index.d.ts +65 -0
  49. package/dist/cjs/index.d.ts.map +1 -0
  50. package/dist/cjs/index.js +47 -0
  51. package/dist/cjs/index.js.map +7 -0
  52. package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
  53. package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
  54. package/dist/cjs/internal/ArtifactLocks.js +93 -0
  55. package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
  56. package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
  57. package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
  58. package/dist/cjs/internal/ArtifactPath.js +74 -0
  59. package/dist/cjs/internal/ArtifactPath.js.map +7 -0
  60. package/dist/cjs/measureBytes.d.ts +18 -0
  61. package/dist/cjs/measureBytes.d.ts.map +1 -0
  62. package/dist/cjs/measureBytes.js +43 -0
  63. package/dist/cjs/measureBytes.js.map +7 -0
  64. package/dist/cjs/package.json +1 -0
  65. package/dist/cjs/snapshotBytes.d.ts +19 -0
  66. package/dist/cjs/snapshotBytes.d.ts.map +1 -0
  67. package/dist/cjs/snapshotBytes.js +41 -0
  68. package/dist/cjs/snapshotBytes.js.map +7 -0
  69. package/dist/cjs/validateDigest.d.ts +22 -0
  70. package/dist/cjs/validateDigest.d.ts.map +1 -0
  71. package/dist/cjs/validateDigest.js +42 -0
  72. package/dist/cjs/validateDigest.js.map +7 -0
  73. package/dist/esm/ArtifactBackupLease.d.ts +34 -0
  74. package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
  75. package/dist/esm/ArtifactBackupLease.js +117 -0
  76. package/dist/esm/ArtifactBackupLease.js.map +1 -0
  77. package/dist/esm/ArtifactStore.d.ts +129 -0
  78. package/dist/esm/ArtifactStore.d.ts.map +1 -0
  79. package/dist/esm/ArtifactStore.js +83 -0
  80. package/dist/esm/ArtifactStore.js.map +1 -0
  81. package/dist/esm/ArtifactStoreError.d.ts +82 -0
  82. package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
  83. package/dist/esm/ArtifactStoreError.js +77 -0
  84. package/dist/esm/ArtifactStoreError.js.map +1 -0
  85. package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
  86. package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/ArtifactStoreMetrics.js +77 -0
  88. package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
  89. package/dist/esm/ArtifactSweep.d.ts +185 -0
  90. package/dist/esm/ArtifactSweep.d.ts.map +1 -0
  91. package/dist/esm/ArtifactSweep.js +202 -0
  92. package/dist/esm/ArtifactSweep.js.map +1 -0
  93. package/dist/esm/CombinedArtifacts.d.ts +103 -0
  94. package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
  95. package/dist/esm/CombinedArtifacts.js +214 -0
  96. package/dist/esm/CombinedArtifacts.js.map +1 -0
  97. package/dist/esm/FileLease.d.ts +113 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +239 -0
  100. package/dist/esm/FileLease.js.map +1 -0
  101. package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
  102. package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
  103. package/dist/esm/FileSystemArtifactStore.js +336 -0
  104. package/dist/esm/FileSystemArtifactStore.js.map +1 -0
  105. package/dist/esm/MemoryArtifactStore.d.ts +25 -0
  106. package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
  107. package/dist/esm/MemoryArtifactStore.js +67 -0
  108. package/dist/esm/MemoryArtifactStore.js.map +1 -0
  109. package/dist/esm/NoopArtifactStore.d.ts +17 -0
  110. package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
  111. package/dist/esm/NoopArtifactStore.js +27 -0
  112. package/dist/esm/NoopArtifactStore.js.map +1 -0
  113. package/dist/esm/RemoteArtifacts.d.ts +237 -0
  114. package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
  115. package/dist/esm/RemoteArtifacts.js +554 -0
  116. package/dist/esm/RemoteArtifacts.js.map +1 -0
  117. package/dist/esm/index.d.ts +65 -0
  118. package/dist/esm/index.d.ts.map +1 -0
  119. package/dist/esm/index.js +65 -0
  120. package/dist/esm/index.js.map +1 -0
  121. package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
  122. package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
  123. package/dist/esm/internal/ArtifactLocks.js +86 -0
  124. package/dist/esm/internal/ArtifactLocks.js.map +1 -0
  125. package/dist/esm/internal/ArtifactPath.d.ts +19 -0
  126. package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
  127. package/dist/esm/internal/ArtifactPath.js +59 -0
  128. package/dist/esm/internal/ArtifactPath.js.map +1 -0
  129. package/dist/esm/measureBytes.d.ts +18 -0
  130. package/dist/esm/measureBytes.d.ts.map +1 -0
  131. package/dist/esm/measureBytes.js +22 -0
  132. package/dist/esm/measureBytes.js.map +1 -0
  133. package/dist/esm/snapshotBytes.d.ts +19 -0
  134. package/dist/esm/snapshotBytes.d.ts.map +1 -0
  135. package/dist/esm/snapshotBytes.js +30 -0
  136. package/dist/esm/snapshotBytes.js.map +1 -0
  137. package/dist/esm/validateDigest.d.ts +22 -0
  138. package/dist/esm/validateDigest.d.ts.map +1 -0
  139. package/dist/esm/validateDigest.js +26 -0
  140. package/dist/esm/validateDigest.js.map +1 -0
  141. package/docs/README.md +200 -0
  142. package/docs/api.md +474 -0
  143. package/docs/concepts/content-addressing.md +174 -0
  144. package/docs/concepts/coordination.md +141 -0
  145. package/docs/concepts/tiers.md +147 -0
  146. package/docs/guides/fence-a-backup.md +123 -0
  147. package/docs/guides/reclaim-disk-space.md +143 -0
  148. package/docs/guides/serve-the-artifact-protocol.md +140 -0
  149. package/docs/guides/share-artifacts-across-machines.md +180 -0
  150. package/docs/guides/test-against-an-artifact-store.md +158 -0
  151. package/docs/installation.md +98 -0
  152. package/docs/quickstart.md +182 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +213 -0
  156. package/src/ArtifactStore.ts +152 -0
  157. package/src/ArtifactStoreError.ts +96 -0
  158. package/src/ArtifactStoreMetrics.ts +83 -0
  159. package/src/ArtifactSweep.ts +336 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +380 -0
  162. package/src/FileSystemArtifactStore.ts +459 -0
  163. package/src/MemoryArtifactStore.ts +83 -0
  164. package/src/NoopArtifactStore.ts +30 -0
  165. package/src/RemoteArtifacts.ts +809 -0
  166. package/src/index.ts +71 -0
  167. package/src/internal/ArtifactLocks.ts +110 -0
  168. package/src/internal/ArtifactPath.ts +72 -0
  169. package/src/measureBytes.ts +28 -0
  170. package/src/snapshotBytes.ts +34 -0
  171. package/src/validateDigest.ts +31 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Two artifact tiers composed into one: local first, remote second, with
3
+ * write-back into the local tier.
4
+ *
5
+ * This is the shape of Bazel's `CombinedCache`
6
+ * (`com.google.devtools.build.lib.remote.CombinedCache`): a read consults the
7
+ * disk cache, falls back to the remote cache only on a miss, and *uploads what it
8
+ * found back into the disk cache* so the next read is local
9
+ * (`downloadActionResultFromRemote`, lines 230-303). A write goes to both.
10
+ *
11
+ * Deviation from Bazel: policy is declared once by the remote tier rather than
12
+ * threaded through every call. `downloadPolicy` controls prefetch and local
13
+ * materialization; composing only the local tier opts out of shared storage.
14
+ *
15
+ * @since 1.0.0-rc.0
16
+ */
17
+
18
+ import * as Cause from "effect/Cause"
19
+ import * as Clock from "effect/Clock"
20
+ import * as Deferred from "effect/Deferred"
21
+ import * as Duration from "effect/Duration"
22
+ import * as Effect from "effect/Effect"
23
+ import * as Exit from "effect/Exit"
24
+ import * as Layer from "effect/Layer"
25
+ import * as Metric from "effect/Metric"
26
+ import * as Option from "effect/Option"
27
+ import * as Schema from "effect/Schema"
28
+ import * as ArtifactStore from "./ArtifactStore.ts"
29
+ import * as ArtifactStoreMetrics from "./ArtifactStoreMetrics.ts"
30
+ import * as RemoteArtifacts from "./RemoteArtifacts.ts"
31
+
32
+ /**
33
+ * The two tiers to compose.
34
+ *
35
+ * @category models
36
+ * @since 1.0.0-rc.0
37
+ * @slop
38
+ */
39
+ export interface Options {
40
+ /** The fast, machine-local tier. Every read tries this one first. */
41
+ readonly local: ArtifactStore.Service
42
+ /** The shared tier. Consulted only on a local miss; written through on put. */
43
+ readonly remote: ArtifactStore.Service
44
+ /**
45
+ * How long a `put` waits for its opportunistic upload to the shared tier
46
+ * before abandoning it. The local digest is already in hand when the upload
47
+ * starts, so the deadline bounds only how long a stalled remote can delay
48
+ * the answer — an abandoned upload is dropped exactly like a refused one.
49
+ * Defaults to 60 seconds, Bazel's `--remote_timeout` default
50
+ * (its `RemoteOptions`).
51
+ */
52
+ readonly uploadTimeout?: Duration.Input | undefined
53
+ /**
54
+ * How long a `get` waits for opportunistic local write-back before
55
+ * interrupting it and returning the verified remote bytes. Defaults to
56
+ * 60 seconds, matching the default upload deadline.
57
+ */
58
+ readonly writeBackTimeout?: Duration.Input | undefined
59
+ /**
60
+ * How eagerly a read materializes a blob into the local tier. Defaults to
61
+ * the policy the remote tier declares (`RemoteArtifacts.Options.downloadPolicy`),
62
+ * and to `all` for a remote tier that declares none.
63
+ *
64
+ * `all` and `toplevel` both write a fetched blob back into the local tier, so
65
+ * the second read is local; they differ only in whether
66
+ * `@smthrs/engine-store`'s `ArtifactSync.hydrate` prefetches. `minimal`
67
+ * serves the bytes without writing them back, so a host that must not
68
+ * accumulate other machines' artifacts never does. `minimal` still repairs a
69
+ * *corrupt* local address, because a rewrite of an address the local tier
70
+ * already claims is repair rather than growth.
71
+ *
72
+ * No policy makes the write-back load-bearing: a local tier that refuses it
73
+ * costs the next read a network round trip, never this read's answer.
74
+ */
75
+ readonly downloadPolicy?: RemoteArtifacts.DownloadPolicy | undefined
76
+ }
77
+
78
+ /**
79
+ * The default deadline on the opportunistic upload. 60 seconds is Bazel's
80
+ * `--remote_timeout` default for its remote cache calls.
81
+ */
82
+ const defaultUploadTimeout = Duration.seconds(60)
83
+
84
+ /** At most one dropped-transfer warning per operation in this window. */
85
+ const droppedWarningIntervalMs = 60_000
86
+
87
+ /** The credential-free reason a dropped transfer failed: its code, never its bytes. */
88
+ const reasonOf = (error: ArtifactStore.ArtifactStoreError | Cause.TimeoutError): string =>
89
+ Cause.isTimeoutError(error) ? error._tag : error.code
90
+
91
+ /**
92
+ * What the local tier answered a read with. A miss and a corrupt address both
93
+ * fall through to the shared tier, but they are not the same fact: only
94
+ * corruption earns a write-back the `minimal` policy would otherwise skip.
95
+ */
96
+ type LocalRead =
97
+ | { readonly _tag: "hit"; readonly bytes: Uint8Array }
98
+ | { readonly _tag: "miss" }
99
+ | { readonly _tag: "corrupt" }
100
+
101
+ /**
102
+ * Composes a local and a remote artifact store.
103
+ *
104
+ * @category constructors
105
+ * @since 1.0.0-rc.0
106
+ * @slop
107
+ */
108
+ export const make = (
109
+ options: Options
110
+ ): Effect.Effect<RemoteArtifacts.Service, ArtifactStore.ArtifactStoreError> =>
111
+ Effect.gen(function*() {
112
+ const { local, remote } = options
113
+ const parsedUploadTimeout = Duration.fromInput(options.uploadTimeout ?? defaultUploadTimeout)
114
+ if (
115
+ Option.isNone(parsedUploadTimeout) ||
116
+ !Number.isFinite(Duration.toMillis(parsedUploadTimeout.value)) ||
117
+ Duration.toMillis(parsedUploadTimeout.value) <= 0
118
+ ) {
119
+ return yield* Effect.fail(
120
+ new ArtifactStore.ArtifactStoreError({
121
+ code: "invalid_configuration",
122
+ message: "invalid combined artifact option: uploadTimeout"
123
+ })
124
+ )
125
+ }
126
+ const uploadTimeout = parsedUploadTimeout.value
127
+ const parsedWriteBackTimeout = Duration.fromInput(options.writeBackTimeout ?? defaultUploadTimeout)
128
+ if (
129
+ Option.isNone(parsedWriteBackTimeout) ||
130
+ !Number.isFinite(Duration.toMillis(parsedWriteBackTimeout.value)) ||
131
+ Duration.toMillis(parsedWriteBackTimeout.value) <= 0
132
+ ) {
133
+ return yield* Effect.fail(
134
+ new ArtifactStore.ArtifactStoreError({
135
+ code: "invalid_configuration",
136
+ message: "invalid combined artifact option: writeBackTimeout"
137
+ })
138
+ )
139
+ }
140
+ const writeBackTimeout = parsedWriteBackTimeout.value
141
+ const downloadPolicy = options.downloadPolicy ?? RemoteArtifacts.downloadPolicyOf(remote) ?? "all"
142
+ if (!Schema.is(RemoteArtifacts.DownloadPolicy)(downloadPolicy)) {
143
+ return yield* Effect.fail(
144
+ new ArtifactStore.ArtifactStoreError({
145
+ code: "invalid_configuration",
146
+ message: "invalid combined artifact option: downloadPolicy"
147
+ })
148
+ )
149
+ }
150
+ /**
151
+ * In-flight uploads, keyed by digest. Two settles in one process that spill
152
+ * the same artifact would otherwise both push the same bytes over the
153
+ * network; the second joins the first's `Deferred` instead. The map holds
154
+ * only in-flight work — the entry is removed before the deferred is
155
+ * completed, so a later `put` of the same digest starts a fresh upload
156
+ * rather than replaying a stale outcome.
157
+ */
158
+ const uploads = new Map<string, Deferred.Deferred<ArtifactStore.Digest, ArtifactStore.ArtifactStoreError>>()
159
+ /**
160
+ * Counts a dropped opportunistic transfer and warns at most once per
161
+ * operation per {@link droppedWarningIntervalMs}, so a shared tier that
162
+ * refuses every request is visible without one log line per artifact.
163
+ */
164
+ const lastWarnedAt = new Map<"put" | "write_back", number>()
165
+ const dropped =
166
+ (operation: "put" | "write_back") => (error: ArtifactStore.ArtifactStoreError | Cause.TimeoutError) =>
167
+ Effect.gen(function*() {
168
+ yield* Metric.update(ArtifactStoreMetrics.remoteFailure[operation], 1)
169
+ const now = yield* Clock.currentTimeMillis
170
+ const last = lastWarnedAt.get(operation)
171
+ if (last !== undefined && now - last < droppedWarningIntervalMs) return
172
+ lastWarnedAt.set(operation, now)
173
+ yield* Effect.logWarning("Combined artifact transfer dropped").pipe(
174
+ Effect.annotateLogs({ operation, reason: reasonOf(error) })
175
+ )
176
+ })
177
+ const uploadInterrupted = (): ArtifactStore.ArtifactStoreError =>
178
+ new ArtifactStore.ArtifactStoreError({
179
+ code: "unavailable",
180
+ message: "the shared upload was interrupted before it settled"
181
+ })
182
+ const uploadOnce = (digest: ArtifactStore.Digest, bytes: Uint8Array) =>
183
+ Effect.suspend(() => {
184
+ const joined = uploads.get(digest)
185
+ if (joined !== undefined) return Deferred.await(joined)
186
+ // Registration and settlement are atomic against interruption. The
187
+ // upload itself stays interruptible — that is how the deadline in `put`
188
+ // cuts it short — but everything around it runs masked: interruption
189
+ // striking between registering the deferred and resolving it would
190
+ // otherwise orphan the entry, and every later `put` of the digest would
191
+ // join a deferred nobody will ever complete.
192
+ return Effect.uninterruptibleMask((restore) =>
193
+ Effect.gen(function*() {
194
+ const deferred = yield* Deferred.make<ArtifactStore.Digest, ArtifactStore.ArtifactStoreError>()
195
+ uploads.set(digest, deferred)
196
+ return yield* restore(remote.put(bytes)).pipe(
197
+ Effect.onExit((exit) => {
198
+ uploads.delete(digest)
199
+ // An interrupted upload resolves the deferred with a typed
200
+ // failure, never with the interruption itself: interruption
201
+ // would tear down every innocent waiter, while a typed refusal
202
+ // is exactly the outcome `put` already drops. The map entry is
203
+ // gone either way, so the next `put` of the digest retries
204
+ // with a fresh upload.
205
+ return Exit.hasInterrupts(exit)
206
+ ? Deferred.fail(deferred, uploadInterrupted())
207
+ : Deferred.done(deferred, exit)
208
+ })
209
+ )
210
+ })
211
+ )
212
+ })
213
+
214
+ const put: ArtifactStore.Service["put"] = Effect.fn("CombinedArtifacts.put")((bytes: Uint8Array) =>
215
+ Effect.flatMap(ArtifactStore.snapshotBytes(bytes), (snapshot) =>
216
+ Effect.gen(function*() {
217
+ // Local first, and its digest is the answer: the local tier is the one
218
+ // this machine's replays resolve against, so a remote tier that is down
219
+ // must not stop an artifact from being recorded locally.
220
+ const digest = yield* local.put(snapshot)
221
+ yield* Effect.annotateCurrentSpan({ digest })
222
+ // Which means the upload is opportunistic, and a refusal is dropped
223
+ // rather than propagated. Failing here would fail whatever produced the
224
+ // bytes — a step's `settle`, say — because a *cache* was unreachable,
225
+ // which is the opposite of the line above. Nothing depends on this
226
+ // upload: what actually guarantees a shared cache entry's blobs are
227
+ // durable is the publication protocol's `findMissing` → upload →
228
+ // confirm, run before the entry is published. A dropped upload here
229
+ // costs that protocol one re-upload, never correctness. The deadline
230
+ // keeps it opportunistic in time as well: a remote that stalls instead
231
+ // of refusing must not hold the local answer hostage, so the upload is
232
+ // interrupted after `uploadTimeout` and abandoned like any refusal.
233
+ yield* uploadOnce(digest, snapshot).pipe(
234
+ Effect.timeout(uploadTimeout),
235
+ Effect.asVoid,
236
+ Effect.catch(dropped("put"))
237
+ )
238
+ return digest
239
+ }))
240
+ )
241
+
242
+ const get: ArtifactStore.Service["get"] = Effect.fn("CombinedArtifacts.get")((digest: string) =>
243
+ Effect.flatMap(ArtifactStore.validateDigest(digest), (validated) =>
244
+ Effect.gen(function*() {
245
+ yield* Effect.annotateCurrentSpan({ digest: validated })
246
+ // A local miss AND local corruption fall through to the remote tier,
247
+ // and they are kept apart because they earn different write-backs. A
248
+ // local `ArtifactStoreError` deliberately does NOT fall through: a
249
+ // host that refused the read has not answered the question, and
250
+ // silently paying the network for it would hide a broken local tier
251
+ // behind a working shared one.
252
+ const cached = yield* local.get(validated).pipe(
253
+ Effect.map((bytes): LocalRead => ({ _tag: "hit", bytes })),
254
+ Effect.catchTags({
255
+ "@smthrs/artifacts/ArtifactMissing": () => Effect.succeed<LocalRead>({ _tag: "miss" }),
256
+ "@smthrs/artifacts/ArtifactCorruption": () => Effect.succeed<LocalRead>({ _tag: "corrupt" })
257
+ })
258
+ )
259
+ if (cached._tag === "hit") return cached.bytes
260
+ const fetched = yield* remote.get(validated)
261
+ // `minimal` reads through without materializing: the caller gets the
262
+ // bytes, the local tier stays exactly as small as it was, and the next
263
+ // read pays the network again. Corruption is the exception every
264
+ // policy makes. Handing the correct bytes to `local.put` lets its own
265
+ // digest verification rewrite the mismatched blob, and an address the
266
+ // local tier already claims — `has` and `findMissing` both report it
267
+ // as present — must be one it can serve, or the publication protocol
268
+ // is told a lie no later read can correct.
269
+ if (downloadPolicy !== "minimal" || cached._tag === "corrupt") {
270
+ // Opportunistic, exactly like `put`'s upload: the answer is already
271
+ // in hand, so a full disk, a read-only mount, or a refused sync must
272
+ // cost the next read a round trip rather than fail this one. A
273
+ // stalled write is interrupted at the deadline so it cannot hold
274
+ // the verified remote answer indefinitely.
275
+ yield* local.put(fetched).pipe(
276
+ Effect.timeout(writeBackTimeout),
277
+ Effect.asVoid,
278
+ Effect.catch(dropped("write_back"))
279
+ )
280
+ }
281
+ return fetched
282
+ }))
283
+ )
284
+
285
+ const has: ArtifactStore.Service["has"] = Effect.fn("CombinedArtifacts.has")((digest: string) =>
286
+ Effect.flatMap(ArtifactStore.validateDigest(digest), (validated) =>
287
+ Effect.annotateCurrentSpan({ digest: validated }).pipe(
288
+ Effect.andThen(
289
+ Effect.flatMap(
290
+ local.has(validated),
291
+ (present) => present ? Effect.succeed(true) : remote.has(validated)
292
+ )
293
+ )
294
+ ))
295
+ )
296
+
297
+ const findMissing: ArtifactStore.Service["findMissing"] = Effect.fn("CombinedArtifacts.findMissing")(
298
+ (digests: Iterable<string>) =>
299
+ Effect.gen(function*() {
300
+ // The iterable is materialized once: it may be single-pass, and both the
301
+ // annotation and the local probe need it.
302
+ const requested = [...new Set(digests)]
303
+ const validated: Array<ArtifactStore.Digest> = []
304
+ for (const digest of requested) {
305
+ validated.push(yield* ArtifactStore.validateDigest(digest))
306
+ }
307
+ // Missing means missing from BOTH tiers, and the remote probe is asked
308
+ // only about what the local tier could not answer — one network round
309
+ // trip, over the smallest possible set. The result stays a subset of the
310
+ // input because each stage filters the previous stage's output.
311
+ return yield* Effect.annotateCurrentSpan({ count: validated.length }).pipe(
312
+ Effect.andThen(
313
+ Effect.flatMap(
314
+ local.findMissing(validated),
315
+ (missingLocally) =>
316
+ missingLocally.length === 0 ? Effect.succeed(missingLocally) : remote.findMissing(missingLocally)
317
+ )
318
+ )
319
+ )
320
+ })
321
+ )
322
+
323
+ return { put, get, has, findMissing, downloadPolicy }
324
+ })
325
+
326
+ /**
327
+ * The two tiers as effects, plus the options {@link make} takes.
328
+ *
329
+ * Both tiers are supplied as *effects* rather than layers because they inhabit
330
+ * the same tag: composing two `Layer<ArtifactStore>` would just shadow one
331
+ * with the other. Pair `ArtifactStore.makeFileSystem` (wrapped in
332
+ * `Effect.sync`) or `Effect.map(FileSystem.FileSystem, ...)` with
333
+ * `RemoteArtifacts.make`.
334
+ *
335
+ * @category models
336
+ * @since 1.0.0-rc.0
337
+ * @slop
338
+ */
339
+ export interface LayerOptions<EL, RL, ER, RR> {
340
+ readonly local: Effect.Effect<ArtifactStore.Service, EL, RL>
341
+ readonly remote: Effect.Effect<ArtifactStore.Service, ER, RR>
342
+ readonly uploadTimeout?: Duration.Input | undefined
343
+ readonly writeBackTimeout?: Duration.Input | undefined
344
+ readonly downloadPolicy?: RemoteArtifacts.DownloadPolicy | undefined
345
+ }
346
+
347
+ /**
348
+ * Provides a local-first artifact store, backed by local and remote effects, as
349
+ * the `ArtifactStore` tag.
350
+ *
351
+ * @category layers
352
+ * @since 1.0.0-rc.0
353
+ */
354
+ export const layer = <EL, RL, ER, RR>(
355
+ options: LayerOptions<EL, RL, ER, RR>
356
+ ): Layer.Layer<ArtifactStore.ArtifactStore, EL | ER | ArtifactStore.ArtifactStoreError, RL | RR> =>
357
+ Layer.effect(ArtifactStore.ArtifactStore)(
358
+ Effect.flatMap(
359
+ Effect.all({ local: options.local, remote: options.remote }),
360
+ ({ local, remote }) =>
361
+ make({
362
+ local,
363
+ remote,
364
+ uploadTimeout: options.uploadTimeout,
365
+ writeBackTimeout: options.writeBackTimeout,
366
+ downloadPolicy: options.downloadPolicy
367
+ })
368
+ )
369
+ )