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

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
package/docs/api.md ADDED
@@ -0,0 +1,474 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every export of @smthrs/artifacts: the store contract and its errors, the filesystem, memory, no-op, HTTP, and combined implementations, the sweep, backup lease, and file lease, and the two metrics."
4
+ ---
5
+
6
+ `@smthrs/artifacts` exports seven modules. Each is available from the root as a
7
+ namespace and from its own subpath:
8
+
9
+ ```ts
10
+ import { ArtifactStore } from "@smthrs/artifacts"
11
+ import * as ArtifactStoreSubpath from "@smthrs/artifacts/ArtifactStore"
12
+ ```
13
+
14
+ Addresses are the SHA-256 digests [`@smthrs/crypto`](/api/crypto) computes
15
+ through the injected `Crypto` service, which with `effect` is the whole
16
+ dependency list.
17
+
18
+ ## Example
19
+
20
+ ```ts
21
+ import { ArtifactStore } from "@smthrs/artifacts"
22
+
23
+ const program = Effect.gen(function*() {
24
+ const store = yield* ArtifactStore.ArtifactStore
25
+ const address = yield* store.put(new TextEncoder().encode("build output"))
26
+ return yield* store.get(address)
27
+ })
28
+ ```
29
+
30
+ ## ArtifactStore
31
+
32
+ `@smthrs/artifacts/ArtifactStore`. The contract, its errors, and the three
33
+ local implementations.
34
+
35
+ ### The contract
36
+
37
+ ```ts
38
+ interface Service {
39
+ readonly put: (bytes: Uint8Array) => Effect<Digest, ArtifactStoreError, Crypto>
40
+ readonly get: (
41
+ digest: string
42
+ ) => Effect<Uint8Array, ArtifactMissing | ArtifactCorruption | ArtifactStoreError, Crypto>
43
+ readonly has: (digest: string) => Effect<boolean, ArtifactStoreError>
44
+ readonly findMissing: (digests: Iterable<string>) => Effect<Array<string>, ArtifactStoreError>
45
+ }
46
+ ```
47
+
48
+ | Member | Behavior |
49
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | `put` | Measures `bytes`, publishes them under that address, and returns it. Idempotent: storing the same bytes twice returns the same address and stores one copy. Every implementation snapshots the caller's buffer when the Effect begins and never retains the caller's reference. |
51
+ | `get` | Returns the bytes at `digest`, having verified that they still hash to it. Every successful read returns a new array. Fails with `ArtifactMissing` for a valid address with no bytes, `ArtifactCorruption` for a mismatch, and `ArtifactStoreError` for a host or transport refusal. |
52
+ | `has` | Whether this tier holds an artifact at `digest`. Does not read or hash the bytes, so it needs no `Crypto`. |
53
+ | `findMissing` | Which of `digests` this tier does not hold. The result is guaranteed to be a subset of the input, deduplicated in first-seen order. |
54
+
55
+ `ArtifactStore` is the service tag, with identity
56
+ `@smthrs/artifacts/ArtifactStore`.
57
+
58
+ Read operations accept a plain `string` rather than the `Digest` brand on
59
+ purpose: an address read back out of a durable row is untrusted input, so the
60
+ store validates it instead of asking every caller to re-brand a persisted
61
+ column. `put` returns the brand, because it measured the bytes itself.
62
+
63
+ ### Digests
64
+
65
+ | Export | Meaning |
66
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | `Digest` | Schema and branded type for exactly 64 lowercase hexadecimal SHA-256 characters, re-exported from `@smthrs/crypto`. |
68
+ | `validateDigest(digest: string)` | `Effect<Digest, ArtifactStoreError>`. Refuses anything other than the canonical representation with code `invalid_digest`. The failure text is a bounded constant, so a hostile value cannot copy itself into a log or a durable error. |
69
+ | `measureBytes(bytes: Uint8Array)` | `Effect<Digest, ArtifactStoreError, Crypto>`. Computes an address with the injected `Crypto` service, never retaining the bytes in an error. |
70
+ | `snapshotBytes(bytes: Uint8Array)` | `Effect<Uint8Array, ArtifactStoreError>`. Copies a caller-owned buffer when the Effect begins. A refused copy fails as `unavailable`, the code for a host refusal, because `Crypto` has not been consulted yet. |
71
+
72
+ Every implementation validates an address before logging it or interpolating it
73
+ into a path or a URL.
74
+
75
+ ### Errors
76
+
77
+ | Export | Shape |
78
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------- |
79
+ | `ArtifactStoreError` | Tag `@smthrs/artifacts/ArtifactStoreError`, fields `code`, `message`, optional `cause`. |
80
+ | `ArtifactMissing` | Tag `@smthrs/artifacts/ArtifactMissing`, fields `code: "artifact_missing"` and `digest`. |
81
+ | `ArtifactCorruption` | Tag `@smthrs/artifacts/ArtifactCorruption`, fields `code: "artifact_corruption"`, `recordedDigest`, `measuredDigest`. |
82
+ | `ArtifactStoreErrorCode` | Schema and type for the five stable codes. |
83
+
84
+ A miss and a corruption are separate tags rather than codes because a
85
+ read-through composition acts on them differently: a miss is an ordinary
86
+ outcome a second tier may satisfy, and a corruption is a violation of the
87
+ store's strongest invariant.
88
+
89
+ | Code | Retryable | Meaning |
90
+ | ----------------------- | --------- | ----------------------------------------------------------- |
91
+ | `invalid_configuration` | No | The caller supplied an option the store refuses. Permanent. |
92
+ | `invalid_digest` | No | The value is not a canonical SHA-256 address. Permanent. |
93
+ | `digest_failed` | Depends | The `Crypto` service failed to compute a digest. |
94
+ | `unavailable` | Depends | A host refused, or a no-op store has no implementation. |
95
+ | `transport_failed` | Depends | The shared tier refused, answered unusably, or timed out. |
96
+
97
+ ### The filesystem store
98
+
99
+ ```ts
100
+ declare const makeFileSystem: (fs: FileSystem, path: Path, options?: FileSystemOptions) => Service
101
+ declare const layerFileSystem: (
102
+ options?: FileSystemOptions
103
+ ) => Layer<ArtifactStore, never, FileSystem | Path>
104
+ ```
105
+
106
+ `path` is the host's `Path` service (`NodePath.layer` on Node). The directory
107
+ sync walks the objects directory's ancestors with it, so a Windows drive path
108
+ syncs its real parents.
109
+
110
+ Publishes at `${directory}/${digest.slice(0, 2)}/${digest}`. Bytes land at a
111
+ unique temp path in the destination fanout directory, are synced when required,
112
+ and are renamed into place. Every deduplicated `put` verifies the existing blob
113
+ and rewrites an unreadable or mismatched address atomically, then freshens the
114
+ blob's modification time so a retention sweep reads it as recently referenced.
115
+
116
+ `findMissing` deduplicates and validates the full input before filesystem
117
+ probes, then checks at most 16 addresses concurrently. Missing digests retain
118
+ first-request order; a failed probe fails the batch as `unavailable`.
119
+
120
+ | `FileSystemOptions` field | Default | Meaning |
121
+ | ------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `directory` | `defaultDirectory` | Where blobs are stored. Workspace-relative, so a workspace can be moved or copied whole. An `ArtifactSweep` over the same store must name the same directory. |
123
+ | `fileMode` | `0o600` | Creation mode for new payload files, restricted by the host umask. Existing blobs are unchanged. |
124
+ | `directoryMode` | `0o700` | Creation mode for objects and fanout directories, restricted by the host umask. Lock directories remain `0700`. |
125
+ | `durability` | `"required"` | `required` syncs the blob, fanout directory, objects directory, and its ancestors and propagates any refusal. `best-effort` tolerates sync refusals. Both modes require exclusive writable handles and symlink inspection. |
126
+ | `coordination` | `"required"` | `required` combines an in-process semaphore with heartbeat-backed lock files shared by every cooperating process. `process` keeps only the semaphore, giving up cross-process exclusion between writers and sweep deletion. |
127
+
128
+ `defaultDirectory` is `.flows/objects`. It is exported because the store and
129
+ its sweep must name the same directory, and a second private copy of the
130
+ literal is exactly how they would drift apart.
131
+
132
+ `required` bounds the writer-versus-sweeper race rather than eliminating it. A
133
+ lock is heartbeated every 10 seconds, reclaimed once it is 60 seconds stale, and
134
+ acquisition gives up after 2 minutes. Contenders that find the same stale lock
135
+ race for a claim file named after its owner, so the lock is moved at most once
136
+ and a fresh replacement is never displaced. A holder whose host stalls past 60
137
+ seconds is still reaped while running. Neither
138
+ `ArtifactSweep.RemoveOptions.ifUnmodifiedSinceMs` nor the backup lease protects
139
+ against that, because both depend on the same lock. Build a
140
+ store and its sweep with the same directory and coordination mode; no runtime
141
+ check can detect a mismatch. See
142
+ [Coordination between processes](./concepts/coordination.md).
143
+
144
+ The store also sweeps its own crash orphans, `.tmp-*` payloads and `.locks/`
145
+ entries older than one hour, once per store instance on its first publication.
146
+ That is scratch reclamation, never garbage collection of published blobs.
147
+
148
+ ### The memory store
149
+
150
+ ```ts
151
+ declare const makeMemory: () => Service
152
+ declare const layerMemory: Layer<ArtifactStore>
153
+ ```
154
+
155
+ For tests and for a browser host with no durable filesystem. It copies at both
156
+ boundaries around a private map, so no reference a caller can still mutate
157
+ aliases the stored content. It does not rehash a read, because its address
158
+ space is private and cannot disagree with itself.
159
+
160
+ `layerMemory` is a value, not a function.
161
+
162
+ ### The no-op store
163
+
164
+ ```ts
165
+ declare const makeNoop: (overrides?: Partial<Service>) => Service
166
+ declare const layerNoop: (overrides?: Partial<Service>) => Layer<ArtifactStore>
167
+ ```
168
+
169
+ Every operation fails with an `unavailable` `ArtifactStoreError` whose message
170
+ is `<method> is unavailable`. Overrides replace individual methods, which is
171
+ how a test states the one behavior it cares about.
172
+
173
+ ## RemoteArtifacts
174
+
175
+ `@smthrs/artifacts/RemoteArtifacts`. The shared tier, spoken over Effect's
176
+ `HttpClient`.
177
+
178
+ ```ts
179
+ declare const make: (options: Options) => Effect<Service, ArtifactStoreError, HttpClient>
180
+ declare const layer: (
181
+ options: Options
182
+ ) => Layer<ArtifactStore.ArtifactStore, ArtifactStoreError, HttpClient>
183
+ ```
184
+
185
+ `Service` extends `ArtifactStore.Service` with one field, `downloadPolicy`.
186
+ Composing `layer` alone makes every read and write a network round trip; the
187
+ intended production shape is `CombinedArtifacts.layer` with this as its remote
188
+ tier.
189
+
190
+ The wire protocol is `GET`, `PUT`, and `HEAD` at `/cas/{digest}` and `POST` at
191
+ `/cas/findMissing`, resolved beneath the endpoint. Every download is bounded and
192
+ digest-verified before it is returned. Redirects are never followed: a fetch
193
+ transport runs with `redirect: "manual"`, and any `3xx` other than the
194
+ resumable-upload `308` fails as an unexpected status, so credential headers
195
+ never reach another origin. The complete server-side contract is in
196
+ [Serve the artifact protocol](./guides/serve-the-artifact-protocol.md).
197
+
198
+ ### Options
199
+
200
+ | Field | Default | Meaning |
201
+ | ----------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
202
+ | `endpoint` | required | The cache root, for example `https://cache.example.com`. A trailing slash is ignored. Refused at construction as `invalid_configuration` when it is not a string, when no `URL` parser accepts it, when the scheme is not `https:` (plain `http:` is allowed only for the literal loopback hosts `localhost`, `127.0.0.1`, and `[::1]`), and when it carries userinfo, a query, or a fragment. The refusal names only the violated rule and never echoes the endpoint. |
203
+ | `headers` | none | Headers sent with every request: this is the credential seam. Construction-time on purpose, because a credential arriving as a step input would be hashed into a step key and persisted everywhere the journal goes. Applied before the protocol's own headers, so no configuration can strip a `content-type`, `content-length`, or `content-range`. |
204
+ | `downloadTimeout` | 60 seconds | One download, including the complete response body. |
205
+ | `uploadTimeout` | 60 seconds | One upload, including every resume probe and chunk. |
206
+ | `requestTimeout` | 60 seconds | One `HEAD` probe, or one `findMissing` batch and its response. |
207
+ | `maxDownloadBytes` | 256 MiB | The largest download buffered. A `Content-Length` past the bound is refused before a body byte is read; an incremental read stops at most one chunk past it. |
208
+ | `maxFindMissingResponseBytes` | 256 KiB | The largest `findMissing` response accepted. May only lower the protocol bound. |
209
+ | `chunkBytes` | absent | Above this many bytes an upload travels as `Content-Range` requests, resuming from the prefix the tier kept. |
210
+ | `downloadPolicy` | `"all"` | How eagerly a composition reading through this tier materializes blobs locally. |
211
+
212
+ `findMissing` validates its input before sending anything, sends at most 1,000
213
+ digests per batch, and filters the response back to requested digests, so a
214
+ tier answering with an unrequested digest cannot make a caller upload bytes it
215
+ never asked about.
216
+
217
+ ### Download policy
218
+
219
+ | Export | Meaning |
220
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
221
+ | `DownloadPolicy` | Schema and type for `"all"`, `"toplevel"`, `"minimal"`. |
222
+ | `downloadPolicies` | A frozen array of all three, in materialization order. |
223
+ | `downloadPolicyOf(store)` | Reads the policy a store declares, or `undefined` for one that declares none, which is every local store and any foreign implementation. |
224
+
225
+ `all` prefetches every referenced blob when a replay is admitted. `toplevel`
226
+ prefetches nothing and materializes a blob on the first read that needs it.
227
+ `minimal` prefetches nothing and materializes nothing.
228
+ `CombinedArtifacts.get` honors the last two;
229
+ [`@smthrs/engine-store`](/api/engine-store)'s `ArtifactSync.hydrate` honors the
230
+ first.
231
+
232
+ ### Chunked uploads
233
+
234
+ With `chunkBytes` set, the client sends a `HEAD` existence probe, then an empty
235
+ `Content-Range: bytes */{total}` probe to discover a retained prefix, then
236
+ ranged chunks:
237
+
238
+ | Response | Client action |
239
+ | ----------------------------- | ----------------------------------------------------------- |
240
+ | `308` | Continue after the reported `Range: bytes=0-{last}` prefix. |
241
+ | `2xx` on the completing chunk | Confirm the complete length with `HEAD`. |
242
+ | `2xx` before completion | Treat the tier as range-unaware and send the whole blob. |
243
+ | `400`, `411`, or `416` | Send the whole blob. |
244
+ | Any other status | Fail the upload as a transport error. |
245
+
246
+ The offset never moves backward. If a server ignores ranges, omits a confirming
247
+ length, or stores a partial body, the whole-blob `PUT` overwrites the partial
248
+ result. Both Smithers cache services answer a ranged `PUT` with `400` and cap
249
+ one request body at 16 MiB, so `chunkBytes` against them degrades
250
+ to one whole-blob `PUT` and a larger artifact is refused with `413`.
251
+
252
+ ## CombinedArtifacts
253
+
254
+ `@smthrs/artifacts/CombinedArtifacts`. Local first, shared second, with
255
+ write-back.
256
+
257
+ ```ts
258
+ declare const make: (options: Options) => Effect<RemoteArtifacts.Service, ArtifactStoreError>
259
+ declare const layer: <EL, RL, ER, RR>(
260
+ options: LayerOptions<EL, RL, ER, RR>
261
+ ) => Layer<ArtifactStore.ArtifactStore, EL | ER | ArtifactStoreError, RL | RR>
262
+ ```
263
+
264
+ | `Options` field | Default | Meaning |
265
+ | ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
266
+ | `local` | required | The fast, machine-local tier, as a `Service`. Every read tries this one first. |
267
+ | `remote` | required | The shared tier, as a `Service`. Consulted only on a local miss or corruption. |
268
+ | `uploadTimeout` | 60 seconds | How long a `put` waits for its opportunistic upload before abandoning it. An abandoned upload is dropped exactly like a refused one. |
269
+ | `writeBackTimeout` | 60 seconds | How long a `get` waits for opportunistic local write-back before interrupting it and returning the verified remote bytes. |
270
+ | `downloadPolicy` | the remote tier's, else `"all"` | Overrides the shared tier's declared policy for this composition. |
271
+
272
+ `LayerOptions` is the same shape with `local` and `remote` as `Effect`s. Both
273
+ tiers are supplied as effects rather than layers because they inhabit the same
274
+ tag: composing two `Layer<ArtifactStore>` would shadow one with the other. Pair
275
+ `Effect.map(Effect.all([FileSystem.FileSystem, Path.Path]), ([fs, path]) => ArtifactStore.makeFileSystem(fs, path))`
276
+ with `RemoteArtifacts.make`.
277
+
278
+ Both timeout options must be finite, positive durations. Invalid values fail
279
+ construction with `ArtifactStoreError` code `invalid_configuration`.
280
+
281
+ **Reads.** The local tier answers first. A hit returns. `ArtifactMissing` and
282
+ `ArtifactCorruption` both fall through to the shared tier, and they are kept
283
+ apart because only corruption earns a write-back that `minimal` would otherwise
284
+ skip. A local `ArtifactStoreError` fails the read rather than falling through:
285
+ a host that refused has not answered the question, and paying for the network
286
+ instead would hide a broken local tier behind a working shared one.
287
+ Local write-back after a remote read is opportunistic. A typed failure or
288
+ `writeBackTimeout` expiry returns the verified remote bytes. Timeout interrupts
289
+ the write-back and waits for its interruption cleanup before returning.
290
+
291
+ **Writes.** `put` publishes locally first and returns the local digest. The
292
+ remote upload is opportunistic, deduplicated in flight by digest, and bounded by
293
+ `uploadTimeout`. A remote refusal or timeout does not fail the operation that
294
+ produced the bytes. The shared-cache publication protocol uses `findMissing`,
295
+ upload, and confirmation before it publishes a cache entry, so an abandoned
296
+ upload costs another transfer rather than correctness.
297
+
298
+ **Probes.** `has` asks local, then remote. `findMissing` asks the remote tier
299
+ only about what the local tier could not answer, so the result stays a subset of
300
+ the input because each stage filters the previous stage's output.
301
+
302
+ `minimal` still writes back after local corruption, because replacing an
303
+ address the local tier already claims is repair rather than growth.
304
+
305
+ ## ArtifactSweep
306
+
307
+ `@smthrs/artifacts/ArtifactSweep`. Host-local enumeration and fenced deletion.
308
+ This surface is deliberately not part of `ArtifactStore.Service`: a remote tier
309
+ can neither enumerate its address space nor accept a delete, and only the
310
+ filesystem store implements it.
311
+
312
+ ```ts
313
+ interface Service {
314
+ readonly inventory: Effect<Array<BlobStat>, ArtifactStoreError>
315
+ readonly remove: (digest: string, options?: RemoveOptions) => Effect<boolean, ArtifactStoreError>
316
+ }
317
+
318
+ declare const makeFileSystem: (fs: FileSystem, options?: SweepOptions) => Service
319
+ declare const layerFileSystem: (options?: SweepOptions) => Layer<ArtifactSweep, never, FileSystem>
320
+ declare const makeNoop: (overrides?: Partial<Service>) => Service
321
+ declare const layerNoop: (overrides?: Partial<Service>) => Layer<ArtifactSweep>
322
+ ```
323
+
324
+ `ArtifactSweep` is the service tag, with identity
325
+ `@smthrs/artifacts/ArtifactSweep`.
326
+
327
+ | Export | Fields |
328
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
+ | `BlobStat` | `digest`, `modifiedAtMs`, `sizeBytes`. |
330
+ | `RemoveOptions` | `ifUnmodifiedSinceMs`: delete only while the blob's mtime is at or before this bound. |
331
+ | `SweepOptions` | `directory` (defaults to `ArtifactStore.defaultDirectory`) and `coordination` (defaults to `required`). No `durability`, because a sweep never writes a blob. |
332
+
333
+ `inventory` returns a `BlobStat` only for canonical fanout files whose
334
+ modification time is measurable. It skips temp files, lock files, foreign
335
+ paths, nested paths, directories sitting at blob addresses, and entries that
336
+ disappear during the scan. A directory that was never created is an empty
337
+ inventory, not a failure.
338
+
339
+ Candidate metadata reads run with concurrency 16 and retain directory-listing
340
+ order. Each candidate keeps its path checks; a failed metadata read skips only
341
+ that candidate.
342
+
343
+ `remove` returns `false` for three outcomes a caller cannot tell apart, all of
344
+ which mean nothing was reclaimed and retrying later is safe: the blob was
345
+ already gone, it failed the `ifUnmodifiedSinceMs` fence, or a live backup lease
346
+ fenced the deletion. The last one is not progress, so a collector counting
347
+ reclaimed bytes must not count it.
348
+
349
+ Under `required`, one deletion costs two lock acquisitions, the per-digest lock
350
+ and the workspace-global backup-lease gate, which is roughly ten filesystem
351
+ operations and two forked heartbeat fibers per blob. The gate is one file for
352
+ the whole workspace, so concurrent deletions serialize through it.
353
+
354
+ The policy this mechanism serves, which digests are live and how long a dead
355
+ blob is kept, belongs to [`@smthrs/engine-store`](/api/engine-store)'s
356
+ `ArtifactGc`. See [Reclaim disk space](./guides/reclaim-disk-space.md).
357
+
358
+ ## ArtifactBackupLease
359
+
360
+ `@smthrs/artifacts/ArtifactBackupLease`. Cross-process exclusion between a
361
+ filesystem backup and sweep deletion.
362
+
363
+ ```ts
364
+ declare const withLease: <A, E, R, E2>(
365
+ fs: FileSystem,
366
+ directory: string,
367
+ effect: Effect<A, E, R>,
368
+ failure: (cause: unknown) => E2
369
+ ) => Effect<A, E | E2, R>
370
+
371
+ declare const unlessActive: <A, E, R, E2>(
372
+ fs: FileSystem,
373
+ directory: string,
374
+ effect: Effect<A, E, R>,
375
+ failure: (cause: unknown) => E2
376
+ ) => Effect<Option<A>, E | E2, R>
377
+ ```
378
+
379
+ `withLease` holds a heartbeat-backed marker while `effect` runs, so no sweep in
380
+ any process using `directory` can delete a blob. Publication stays
381
+ unconstrained: new blobs may appear during a backup, but a blob already
382
+ referenced by the frozen database cannot disappear underneath it. Acquisition
383
+ gives up after 2 minutes and fails through `failure`. A crashed lease becomes
384
+ reclaimable once its heartbeat is 60 seconds stale, and finding no marker to
385
+ release is the ordinary end of a slow lease rather than a fault.
386
+
387
+ `unlessActive` checks the marker and runs `effect` under the same
388
+ workspace-global gate, so the check and the operation cannot be separated. It
389
+ returns `None` when a live backup deliberately fenced the operation, which
390
+ `ArtifactSweep.remove` reports as `false`.
391
+
392
+ `failure` maps an unknown host cause into the caller's error type, which is how
393
+ these combinators stay usable from a tool with its own error channel. See
394
+ [Fence a backup against the sweep](./guides/fence-a-backup.md).
395
+
396
+ ## FileLease
397
+
398
+ `@smthrs/artifacts/FileLease`. The cross-process lock-file lease under the
399
+ per-digest locks and `ArtifactBackupLease`, exported for other packages that
400
+ need the same fence; `@smthrs/engine-store` uses it for the workspace commit
401
+ lock.
402
+
403
+ ```ts
404
+ declare const withLease: <A, E, R, E2>(
405
+ fs: FileSystem,
406
+ lockPath: string,
407
+ effect: Effect<A, E, R>,
408
+ failure: (cause: unknown) => E2,
409
+ options?: Options
410
+ ) => Effect<A, E | E2, R>
411
+
412
+ declare const hold: <A, E, R, E2>(
413
+ fs: FileSystem,
414
+ lockPath: string,
415
+ effect: Effect<A, E, R>,
416
+ failure: (cause: unknown) => E2,
417
+ options?: HoldOptions
418
+ ) => Effect<A, E | E2, R>
419
+ ```
420
+
421
+ `withLease` creates `lockPath` with `wx`, writes a unique owner token, and
422
+ refreshes its mtime while `effect` runs. It reclaims a lock whose mtime is
423
+ older than the stale bound, at most once per lock generation, and fails with
424
+ `failure(TimeoutError)` when acquisition misses its deadline. Every other host
425
+ refusal reaches `failure` as the `PlatformError`. A directory at `lockPath` is
426
+ treated as a lock with no readable owner and reclaimed on the same bound.
427
+ `hold` is the same lease without a deadline, for a caller that bounds a wider
428
+ wait itself; `onAcquired` runs once the lease is held. `guard`, when given,
429
+ runs before every create, heartbeat, rename, and remove of the lock, claim, or
430
+ tombstone, and its failure refuses that step through `failure`.
431
+
432
+ | Option | Default | Meaning |
433
+ | ---------------- | -------------- | ------------------------------------------------ |
434
+ | `heartbeatEvery` | `"10 seconds"` | How often the holder refreshes the lock's mtime. |
435
+ | `staleAfterMs` | `60_000` | Age past which a lock or reclaim claim is stale. |
436
+ | `acquireWithin` | `"2 minutes"` | Acquisition deadline (`withLease` only). |
437
+ | `retryEvery` | `"25 millis"` | Sleep between attempts while contended. |
438
+ | `label` | `"File lease"` | Names the lock in the reclaimed-holder warning. |
439
+ | `annotations` | none | Extra fields logged with that warning. |
440
+
441
+ The host needs exclusive `wx` file creation, `readFileString`, `stat` with an
442
+ mtime, `utimes`, `rename`, and `remove`. Reclaim writes siblings named
443
+ `<lockPath>.reclaim-<token>` and `<lockPath>.stale-<token>`. The fence is
444
+ bounded: see [Coordination](./concepts/coordination.md).
445
+
446
+ ## ArtifactStoreMetrics
447
+
448
+ `@smthrs/artifacts/ArtifactStoreMetrics`. Metric handles only; no exporter
449
+ ships in this package.
450
+
451
+ | Export | Counter | Updated by |
452
+ | ---------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------- |
453
+ | `puts` | `flows_artifact_puts` | Successful filesystem and memory puts, including deduplicated ones. |
454
+ | `gets` | `flows_artifact_gets` | Successful filesystem and memory gets. |
455
+ | `remoteFailures` | `flows_artifact_remote_failures` | Shared uploads and local write-backs `CombinedArtifacts` dropped, by `operation` (`put`, `write_back`). |
456
+
457
+ `remoteFailure.put` and `remoteFailure.write_back` are the attributed views.
458
+ Drops also log a warning naming the operation and error code, at most once a
459
+ minute per operation.
460
+
461
+ `RemoteArtifacts` is deliberately uninstrumented and the counters carry no tier
462
+ attribute, so read them as local artifact store traffic. A combined read the
463
+ shared tier serves increments no get; the write-back that materializes it
464
+ increments a put indistinguishable from a producer publishing new bytes.
465
+ Missing and corrupt reads are counted nowhere: they are error evidence, not
466
+ throughput.
467
+
468
+ ## Reclaiming published blobs
469
+
470
+ Reclaiming published blobs never happens as a side effect of a store call. The
471
+ filesystem store sweeps only stale scratch and lock files. The mark policy and
472
+ grace period for published blobs belong to
473
+ [`@smthrs/engine-store`](/api/engine-store), which also owns artifact
474
+ publication ordering.
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: "Content addressing"
3
+ description: "Why an artifact's address is a measurement of its bytes, what that buys, and the four invariants the store enforces to keep an address and its content from ever disagreeing."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ An artifact has no name. Its address is the SHA-256 digest of its own bytes,
9
+ spelled as 64 lowercase hexadecimal characters, and the store computed it by
10
+ hashing what the caller handed over. That single decision is where every other
11
+ property in this package comes from.
12
+
13
+ Three things follow immediately. Two callers who produce the same bytes produce
14
+ the same address, so storing the second copy is free. An address is a proof
15
+ obligation the store can check at any moment, so a read can verify rather than
16
+ trust. And a digest is safe to write into a durable row, ship over a network,
17
+ or print in a log, because it reveals nothing about where the bytes live or who
18
+ produced them.
19
+
20
+ Smithers uses the same idea one layer up: a step key is derived from canonical
21
+ content rather than from an activity's name, which is what
22
+ [content addressing on smithers.sh](/docs/concepts/content-addressing/)
23
+ describes. This package is the byte-level half of that story.
24
+
25
+ ## A digest is a value, and an untrusted one
26
+
27
+ `ArtifactStore.Digest` is both the branded type and the schema for the
28
+ canonical representation. `put` returns the brand, because it measured the
29
+ bytes itself and can vouch for the form.
30
+
31
+ Every read operation accepts a plain `string` instead. That is deliberate: a
32
+ digest read back out of a durable row, a cache entry, or an HTTP body is
33
+ untrusted input, and asking every caller to re-brand a persisted column would
34
+ only move the trust question somewhere less careful. The store validates
35
+ instead. `ArtifactStore.validateDigest` refuses anything that is not exactly 64
36
+ lowercase hexadecimal characters, and it runs before any implementation logs a
37
+ digest or interpolates one into a path or a URL:
38
+
39
+ ```ts
40
+ import * as ArtifactStore from "@smthrs/artifacts/ArtifactStore"
41
+ import * as Effect from "effect/Effect"
42
+
43
+ /** Prints `invalid_digest` rather than reaching for `../../etc/passwd`. */
44
+ const checked = ArtifactStore.validateDigest("../../etc/passwd").pipe(
45
+ Effect.catchTag("@smthrs/artifacts/ArtifactStoreError", (failure) => Effect.succeed(failure.code))
46
+ )
47
+
48
+ console.log(await Effect.runPromise(checked))
49
+ ```
50
+
51
+ The failure message is a constant. A hostile multi-megabyte value cannot copy
52
+ itself into a log line or a durable error by being rejected.
53
+
54
+ ## The four invariants
55
+
56
+ ### Every read is digest verified
57
+
58
+ `get` measures the bytes it found and compares them against the address it was
59
+ asked for. A mismatch is `ArtifactCorruption`, carrying both the recorded and
60
+ the measured digest, and the bytes are never returned. This is what makes a
61
+ truncated blob from a crashing writer, a failing disk, or a shared tier serving
62
+ the wrong object into a loud failure rather than a silently wrong replay.
63
+
64
+ The in-memory store is the deliberate exception. Its address space is a private
65
+ `Map` keyed by the digest it measured on the way in, `put` stores a copy of the
66
+ caller's array, and `get` hands out a copy of the stored one. No reference a
67
+ caller can still mutate aliases the stored content, so there is no window in
68
+ which the address and the content can disagree. The filesystem and HTTP stores
69
+ verify because their address spaces are genuinely shared.
70
+
71
+ ### Publication is atomic
72
+
73
+ A plain write to the canonical path could be observed, or survive a crash, as a
74
+ partial file that every later read of that digest would trust. The filesystem
75
+ store instead writes to a temp path inside the destination fanout directory,
76
+ fsyncs it, and renames it into place. The rename never crosses a filesystem
77
+ because the temp path is a sibling of the blob.
78
+
79
+ Temp names carry a random per-store token drawn from Effect's `Random`, so two
80
+ processes publishing the same digest into one workspace never share a scratch
81
+ path and never clobber each other into torn bytes at the content address. The
82
+ token enters no persisted identity, so its randomness is invisible to replay.
83
+
84
+ `FileSystemOptions.durability` controls the fsync. `required`, the default,
85
+ reports success only after syncing the blob, its fanout directory, the objects
86
+ directory, and each ancestor through the relative or filesystem root. This
87
+ persists new fanout and lock directory entries and nested configured directories.
88
+ Every put repeats these barriers, including a deduplicated retry after an
89
+ interrupted publication. A sync refusal fails with `ArtifactStoreError` code
90
+ `unavailable`. `best-effort` is the explicit weaker capability for a
91
+ host that cannot sync file or directory handles. Both modes require exclusive
92
+ writable handles and symlink inspection; unsupported hosts fail as `unavailable`.
93
+
94
+ ### An existing blob is verified on every put
95
+
96
+ The store does not treat "the path exists" as "the bytes are correct", and it
97
+ does not remember a verification it already did. The objects directory is
98
+ shared by the whole workspace, so a blob can change behind one store's back. A
99
+ remembered proof would let a later put report success over corrupt bytes
100
+ without repairing them, and `get` would then refuse that digest forever even
101
+ though every put held the cure.
102
+
103
+ So every put reads the existing blob, measures it, and only skips the write on
104
+ a verified match. A mismatch, or a read the host refuses, falls through to the
105
+ atomic rewrite and heals the address. Re-verifying costs a constant factor, not
106
+ a new asymptote: a put already pays one hash over its own input.
107
+
108
+ ### A deduplicated put freshens the blob
109
+
110
+ When a put deduplicates, the store stamps the blob's modification time. This is
111
+ git's loose-object freshening, and the touch Bazel's disk cache performs on a
112
+ hit. It matters because mtime is the age evidence a retention sweep fences its
113
+ deletions on. Re-publishing old bytes has to read as a recent reference, or the
114
+ grace period cannot protect the cache entry recorded moments later.
115
+
116
+ The freshen is best effort. On a host with no `utimes`, a failure over a blob
117
+ that still exists keeps the dedupe skip and accepts git's own
118
+ freshen-versus-prune race. A failure over a blob that vanished, because a sweep
119
+ won, falls through to the atomic rewrite instead.
120
+
121
+ ## What an address does not tell you
122
+
123
+ An address says what the bytes are. It says nothing about whether anything
124
+ still wants them. This package therefore never reclaims a published artifact as
125
+ a side effect of a store call: `ArtifactSweep` is the deletion surface, and the
126
+ mark phase that decides which digests are live belongs to
127
+ [`@smthrs/engine-store`](/api/engine-store)'s `ArtifactGc`, the only place the
128
+ durable roots are visible. See
129
+ [Reclaim disk space](../guides/reclaim-disk-space.md).
130
+
131
+ The one thing a store does clean up on its own is its own scratch: `.tmp-*`
132
+ payloads and `.locks/` files older than an hour, swept once per store instance
133
+ on its first publication. Those are crash orphans that nothing else observes.
134
+
135
+ ## The layout on disk
136
+
137
+ The filesystem store publishes at `<directory>/<first two hex>/<digest>`, with
138
+ the directory defaulting to `ArtifactStore.defaultDirectory`, `.flows/objects`.
139
+
140
+ The two-hex fanout is Bazel's `DiskCacheClient` layout, taken "to bypass
141
+ possible folder file count limits". The directory is workspace-relative rather
142
+ than absolute so a workspace can be moved or copied whole and still resolve its
143
+ own artifacts, and so a sandbox that mounts the workspace inherits them.
144
+
145
+ Addresses published under the pre-1.0 flat `<directory>/<digest>` layout are
146
+ cache misses that re-publish. There is no compatibility shim.
147
+
148
+ ## Prior art
149
+
150
+ The contract's ergonomics follow Effect's own `KeyValueStore`
151
+ (`effect/unstable/persistence/KeyValueStore`): one small set of total
152
+ operations over one address space, so a memory, filesystem, or network
153
+ implementation is the same shape. `findMissing` is Bazel's
154
+ `MissingDigestsFinder`, one batched round trip whose result is guaranteed to be
155
+ a subset of its input, because a per-digest existence probe over a network tier
156
+ is the wrong shape entirely.
157
+
158
+ The mechanics come from Bazel's remote-cache classes:
159
+
160
+ | Taken from | What |
161
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
162
+ | [`disk/DiskCacheClient.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java) | The two-hex fanout layout and the fsync of the temp file before the rename |
163
+ | [`http/HttpCacheClient.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/http/HttpCacheClient.java) | The wire protocol: CAS blobs under `/cas/base16-key`, `PUT` to upload, `GET` to download |
164
+ | [`common/MissingDigestsFinder.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/common/MissingDigestsFinder.java) | `findMissing` as one batched probe whose result is a subset of its input |
165
+ | [`CombinedCache.java`](https://github.com/bazelbuild/bazel/blob/master/src/main/java/com/google/devtools/build/lib/remote/CombinedCache.java) | Local first, remote second, write back what the remote returned |
166
+
167
+ Where this package deviates is documented in [The three tiers](./tiers.md).
168
+
169
+ ## Related
170
+
171
+ - [The three tiers](./tiers.md): how local, shared, and combined stores compose.
172
+ - [Coordination between processes](./coordination.md): the locks and fences
173
+ that keep two processes out of each other's way in one objects directory.
174
+ - [API reference](../api.md): the exact signatures and error shapes.