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

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 +175 -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 +183 -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 +171 -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 +117 -0
  29. package/dist/cjs/FileLease.d.ts.map +1 -0
  30. package/dist/cjs/FileLease.js +208 -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 +126 -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 +210 -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 +117 -0
  98. package/dist/esm/FileLease.d.ts.map +1 -0
  99. package/dist/esm/FileLease.js +249 -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 +557 -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 +202 -0
  142. package/docs/api.md +484 -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 +102 -0
  152. package/docs/quickstart.md +183 -0
  153. package/docs/troubleshooting.md +232 -0
  154. package/package.json +179 -3
  155. package/src/ArtifactBackupLease.ts +223 -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 +346 -0
  160. package/src/CombinedArtifacts.ts +369 -0
  161. package/src/FileLease.ts +397 -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 +814 -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/package.json CHANGED
@@ -1,6 +1,182 @@
1
1
  {
2
2
  "name": "@smthrs/artifacts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "1.0.0-rc.12",
5
+ "license": "MIT",
6
+ "smthrs": {
7
+ "group": "engine"
8
+ },
9
+ "description": "Effect service for the Smithers content-addressed artifact store, local and remote",
10
+ "homepage": "https://github.com/smithersai/smithers",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/smithersai/smithers.git",
14
+ "directory": "packages/smithers/flows/artifacts"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/smithersai/smithers/issues"
18
+ },
19
+ "tags": [
20
+ "typescript",
21
+ "effect",
22
+ "cache",
23
+ "content-addressed"
24
+ ],
25
+ "keywords": [
26
+ "typescript",
27
+ "effect",
28
+ "cache",
29
+ "content-addressed"
30
+ ],
31
+ "engines": {
32
+ "node": ">=26.4.0"
33
+ },
34
+ "sideEffects": [],
35
+ "exports": {
36
+ "./package.json": "./package.json",
37
+ ".": {
38
+ "import": {
39
+ "types": "./dist/esm/index.d.ts",
40
+ "default": "./dist/esm/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/cjs/index.d.ts",
44
+ "default": "./dist/cjs/index.js"
45
+ }
46
+ },
47
+ "./internal/*": null,
48
+ "./*/index": null,
49
+ "./ArtifactBackupLease": {
50
+ "import": {
51
+ "types": "./dist/esm/ArtifactBackupLease.d.ts",
52
+ "default": "./dist/esm/ArtifactBackupLease.js"
53
+ },
54
+ "require": {
55
+ "types": "./dist/cjs/ArtifactBackupLease.d.ts",
56
+ "default": "./dist/cjs/ArtifactBackupLease.js"
57
+ }
58
+ },
59
+ "./ArtifactStore": {
60
+ "import": {
61
+ "types": "./dist/esm/ArtifactStore.d.ts",
62
+ "default": "./dist/esm/ArtifactStore.js"
63
+ },
64
+ "require": {
65
+ "types": "./dist/cjs/ArtifactStore.d.ts",
66
+ "default": "./dist/cjs/ArtifactStore.js"
67
+ }
68
+ },
69
+ "./ArtifactStoreMetrics": {
70
+ "import": {
71
+ "types": "./dist/esm/ArtifactStoreMetrics.d.ts",
72
+ "default": "./dist/esm/ArtifactStoreMetrics.js"
73
+ },
74
+ "require": {
75
+ "types": "./dist/cjs/ArtifactStoreMetrics.d.ts",
76
+ "default": "./dist/cjs/ArtifactStoreMetrics.js"
77
+ }
78
+ },
79
+ "./ArtifactSweep": {
80
+ "import": {
81
+ "types": "./dist/esm/ArtifactSweep.d.ts",
82
+ "default": "./dist/esm/ArtifactSweep.js"
83
+ },
84
+ "require": {
85
+ "types": "./dist/cjs/ArtifactSweep.d.ts",
86
+ "default": "./dist/cjs/ArtifactSweep.js"
87
+ }
88
+ },
89
+ "./CombinedArtifacts": {
90
+ "import": {
91
+ "types": "./dist/esm/CombinedArtifacts.d.ts",
92
+ "default": "./dist/esm/CombinedArtifacts.js"
93
+ },
94
+ "require": {
95
+ "types": "./dist/cjs/CombinedArtifacts.d.ts",
96
+ "default": "./dist/cjs/CombinedArtifacts.js"
97
+ }
98
+ },
99
+ "./FileLease": {
100
+ "import": {
101
+ "types": "./dist/esm/FileLease.d.ts",
102
+ "default": "./dist/esm/FileLease.js"
103
+ },
104
+ "require": {
105
+ "types": "./dist/cjs/FileLease.d.ts",
106
+ "default": "./dist/cjs/FileLease.js"
107
+ }
108
+ },
109
+ "./RemoteArtifacts": {
110
+ "import": {
111
+ "types": "./dist/esm/RemoteArtifacts.d.ts",
112
+ "default": "./dist/esm/RemoteArtifacts.js"
113
+ },
114
+ "require": {
115
+ "types": "./dist/cjs/RemoteArtifacts.d.ts",
116
+ "default": "./dist/cjs/RemoteArtifacts.js"
117
+ }
118
+ },
119
+ "./index": {
120
+ "import": {
121
+ "types": "./dist/esm/index.d.ts",
122
+ "default": "./dist/esm/index.js"
123
+ },
124
+ "require": {
125
+ "types": "./dist/cjs/index.d.ts",
126
+ "default": "./dist/cjs/index.js"
127
+ }
128
+ }
129
+ },
130
+ "files": [
131
+ "src/**/*.ts",
132
+ "dist/**/*.js",
133
+ "dist/**/*.js.map",
134
+ "dist/**/*.d.ts",
135
+ "dist/**/*.d.ts.map",
136
+ "dist/**/package.json",
137
+ "LICENSE",
138
+ "README.md",
139
+ "CHANGELOG.md",
140
+ "docs/"
141
+ ],
142
+ "publishConfig": {
143
+ "access": "public",
144
+ "provenance": true,
145
+ "tag": "next"
146
+ },
147
+ "dependencies": {
148
+ "@smthrs/crypto": "1.0.0-rc.12"
149
+ },
150
+ "devDependencies": {
151
+ "@effect/language-service": "0.87.2",
152
+ "@effect/platform-node": "4.0.4",
153
+ "@effect/vitest": "4.0.4",
154
+ "@eslint/js": "9.39.5",
155
+ "@types/node": "26.4.1",
156
+ "@vitest/coverage-v8": "5.0.0",
157
+ "dprint": "0.57.1",
158
+ "effect": "4.0.4",
159
+ "esbuild": "0.28.2",
160
+ "eslint": "9.39.5",
161
+ "eslint-import-resolver-typescript": "4.4.5",
162
+ "eslint-plugin-import": "2.32.0",
163
+ "eslint-plugin-jsdoc": "64.3.4",
164
+ "eslint-plugin-unicorn": "65.0.0",
165
+ "madge": "8.0.0",
166
+ "typescript": "5.9.3",
167
+ "typescript-eslint": "8.69.0",
168
+ "vitest": "5.0.0"
169
+ },
170
+ "peerDependencies": {
171
+ "effect": "^4.0.4"
172
+ },
173
+ "scripts": {
174
+ "lint": "eslint src --max-warnings=0 && dprint check",
175
+ "format": "dprint fmt",
176
+ "build": "node scripts/build.mjs",
177
+ "check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
178
+ "circular": "node scripts/circular.mjs",
179
+ "test": "vitest",
180
+ "coverage": "vitest --coverage"
181
+ }
6
182
  }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Cross-process exclusion between a filesystem artifact backup and garbage
3
+ * collection.
4
+ *
5
+ * A backup holds one heartbeat-backed marker for the duration of its database
6
+ * snapshot and blob copy. Sweep deletion checks that marker while holding the
7
+ * same short-lived global gate. Publication remains unconstrained: new blobs
8
+ * may appear during a backup, but a blob already referenced by the frozen
9
+ * database cannot disappear underneath it.
10
+ *
11
+ * @since 1.0.0-rc.0
12
+ */
13
+
14
+ import * as Clock from "effect/Clock"
15
+ import * as Effect from "effect/Effect"
16
+ import type * as FileSystem from "effect/FileSystem"
17
+ import * as Option from "effect/Option"
18
+ import * as PlatformError from "effect/PlatformError"
19
+ import * as Random from "effect/Random"
20
+ import * as ArtifactLocks from "./internal/ArtifactLocks.ts"
21
+
22
+ const markerName = ".backup-lease"
23
+ const gateName = ".backup-lease-gate"
24
+ const heartbeatEvery = "10 seconds"
25
+ const staleAfterMs = 60_000
26
+ const acquireWithin = "2 minutes"
27
+ const retryEvery = "25 millis"
28
+
29
+ const isReason = (cause: unknown, tag: PlatformError.SystemErrorTag): boolean =>
30
+ cause instanceof PlatformError.PlatformError && cause.reason._tag === tag
31
+
32
+ const token = Effect.gen(function*() {
33
+ const first = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
34
+ const second = yield* Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER, { halfOpen: true })
35
+ return `${first.toString(36)}-${second.toString(36)}-${yield* Clock.currentTimeMillis}`
36
+ })
37
+
38
+ const gate = <A, E, R, E2>(
39
+ fs: FileSystem.FileSystem,
40
+ directory: string,
41
+ effect: Effect.Effect<A, E, R>,
42
+ failure: (cause: unknown) => E2
43
+ ): Effect.Effect<A, E | E2, R> => ArtifactLocks.withDigest(fs, directory, gateName, effect, failure)
44
+
45
+ const activeMarker = <E>(
46
+ fs: FileSystem.FileSystem,
47
+ directory: string,
48
+ failure: (cause: unknown) => E
49
+ ): Effect.Effect<boolean, E> =>
50
+ Effect.gen(function*() {
51
+ const path = `${directory}/${markerName}`
52
+ const info = yield* fs.stat(path).pipe(
53
+ Effect.map(Option.some),
54
+ Effect.catch((cause): Effect.Effect<Option.Option<FileSystem.File.Info>, E> =>
55
+ isReason(cause, "NotFound") ? Effect.succeed(Option.none()) : Effect.fail(failure(cause))
56
+ )
57
+ )
58
+ if (Option.isNone(info)) return false
59
+ const modified = Option.getOrUndefined(info.value.mtime)
60
+ const now = yield* Clock.currentTimeMillis
61
+ if (modified === undefined || now - modified.getTime() <= staleAfterMs) return true
62
+ yield* fs.remove(path).pipe(
63
+ Effect.catch((cause): Effect.Effect<void, E> =>
64
+ isReason(cause, "NotFound") ? Effect.void : Effect.fail(failure(cause))
65
+ )
66
+ )
67
+ return false
68
+ })
69
+
70
+ /**
71
+ * Runs `effect` while sweep deletion is fenced across every process using
72
+ * this objects directory. A crashed holder is recovered after its heartbeat
73
+ * becomes stale.
74
+ *
75
+ * @category combinators
76
+ * @since 1.0.0-rc.0
77
+ */
78
+ export const withLease = <A, E, R, E2>(
79
+ fs: FileSystem.FileSystem,
80
+ directory: string,
81
+ effect: Effect.Effect<A, E, R>,
82
+ failure: (cause: unknown) => E2
83
+ ): Effect.Effect<A, E | E2, R> =>
84
+ Effect.gen(function*() {
85
+ const owner = yield* token
86
+ const marker = `${directory}/${markerName}`
87
+ yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(failure))
88
+
89
+ let ownsMarker = false
90
+ const acquire = Effect.gen(function*() {
91
+ while (true) {
92
+ const acquired = yield* gate(
93
+ fs,
94
+ directory,
95
+ Effect.gen(function*() {
96
+ if (yield* activeMarker(fs, directory, failure)) return false
97
+ return yield* Effect.uninterruptible(
98
+ fs.writeFileString(marker, owner, { flag: "wx", mode: 0o600 }).pipe(
99
+ Effect.andThen(Effect.sync(() => {
100
+ ownsMarker = true
101
+ return true
102
+ })),
103
+ Effect.catch((cause): Effect.Effect<boolean, E2> =>
104
+ isReason(cause, "AlreadyExists") ? Effect.succeed(false) : Effect.fail(failure(cause))
105
+ )
106
+ )
107
+ )
108
+ }),
109
+ failure
110
+ )
111
+ if (acquired) return
112
+ yield* Effect.sleep(retryEvery)
113
+ }
114
+ }).pipe(
115
+ Effect.timeout(acquireWithin),
116
+ Effect.catchTag("TimeoutError", (cause) => Effect.fail(failure(cause)))
117
+ )
118
+
119
+ // Install marker cleanup before acquisition, including gate release. The
120
+ // marker write and ownership bookkeeping complete in one interruption mask.
121
+ return yield* Effect.acquireUseRelease(
122
+ Effect.void,
123
+ () =>
124
+ acquire.pipe(Effect.andThen(
125
+ Effect.scoped(Effect.gen(function*() {
126
+ yield* Effect.forkScoped(
127
+ Effect.gen(function*() {
128
+ while (true) {
129
+ yield* Effect.sleep(heartbeatEvery)
130
+ const reclaimed = yield* gate(
131
+ fs,
132
+ directory,
133
+ fs.readFileString(marker).pipe(
134
+ Effect.map(Option.some),
135
+ Effect.catch((cause) =>
136
+ isReason(cause, "NotFound") ? Effect.succeed(Option.none<string>()) : Effect.fail(cause)
137
+ ),
138
+ Effect.flatMap((found) => {
139
+ // A missing or foreign marker means a sweep judged this
140
+ // lease stale and reaped it: deletion is no longer
141
+ // fenced while the backup still copies. The sibling
142
+ // `internal/ArtifactLocks.ts` heartbeat reports the same
143
+ // loss the same way. A reaped marker is never handed
144
+ // back, so the loss is reported once and the heartbeat
145
+ // ends instead of repeating it every tick.
146
+ if (Option.getOrUndefined(found) !== owner) {
147
+ return Effect.as(
148
+ Effect.logWarning(
149
+ "Artifact backup lease was reclaimed while its backup was still running"
150
+ ),
151
+ true
152
+ )
153
+ }
154
+ return Effect.flatMap(Clock.currentTimeMillis, (now) => {
155
+ const timestamp = new Date(now)
156
+ return Effect.as(fs.utimes(marker, timestamp, timestamp), false)
157
+ })
158
+ }),
159
+ Effect.mapError(failure)
160
+ ),
161
+ failure
162
+ ).pipe(
163
+ // A failed heartbeat is retried on the next tick, but a
164
+ // minute of them lets the marker go stale, so each one is
165
+ // reported rather than dropped.
166
+ Effect.catch((cause) =>
167
+ Effect.as(Effect.logWarning("Artifact backup lease heartbeat failed", cause), false)
168
+ )
169
+ )
170
+ if (reclaimed) return
171
+ }
172
+ })
173
+ )
174
+ return yield* effect
175
+ }))
176
+ )),
177
+ () =>
178
+ ownsMarker
179
+ ? gate(
180
+ fs,
181
+ directory,
182
+ fs.readFileString(marker).pipe(
183
+ Effect.flatMap((found) => found === owner ? fs.remove(marker) : Effect.void),
184
+ // A backup whose heartbeat lapsed has its marker reaped by whoever
185
+ // noticed, so `NotFound` is the ordinary end of a slow lease, not a
186
+ // fault: there is nothing left to release and nothing left to leak.
187
+ // The sibling release in `internal/ArtifactLocks.ts` classifies the
188
+ // same cause the same way.
189
+ Effect.catch((cause): Effect.Effect<void, PlatformError.PlatformError> =>
190
+ isReason(cause, "NotFound") ? Effect.void : Effect.fail(cause)
191
+ )
192
+ ),
193
+ failure
194
+ ).pipe(
195
+ Effect.catch((cause) => Effect.logWarning("Artifact backup lease release failed", cause))
196
+ )
197
+ : Effect.void
198
+ )
199
+ })
200
+
201
+ /**
202
+ * Runs one sweep deletion only when no live backup lease exists. The marker
203
+ * check and `effect` share the global gate, closing the check/delete race.
204
+ * `None` means a backup deliberately fenced the operation.
205
+ *
206
+ * @category combinators
207
+ * @since 1.0.0-rc.0
208
+ */
209
+ export const unlessActive = <A, E, R, E2>(
210
+ fs: FileSystem.FileSystem,
211
+ directory: string,
212
+ effect: Effect.Effect<A, E, R>,
213
+ failure: (cause: unknown) => E2
214
+ ): Effect.Effect<Option.Option<A>, E | E2, R> =>
215
+ gate(
216
+ fs,
217
+ directory,
218
+ Effect.gen(function*() {
219
+ if (yield* activeMarker(fs, directory, failure)) return Option.none<A>()
220
+ return Option.some(yield* effect)
221
+ }),
222
+ failure
223
+ )
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The content-addressed artifact store: bytes addressed by their own SHA-256
3
+ * digest.
4
+ *
5
+ * It is deliberately *not* the step cache: the step cache maps a step key to a
6
+ * recorded result, while large result bytes live here under their digest. The
7
+ * two tiers remain separate because artifacts must be published before a cache
8
+ * record may reference them. See the package README and
9
+ * {@link https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/plan/docs/concepts/step-keys.md | step-key documentation}.
10
+ *
11
+ * This module defines the contract: the address schema, the service interface
12
+ * and tag, and one layer per local implementation. The errors, byte helpers,
13
+ * and implementations are defined in the modules named for them and re-exported
14
+ * here unchanged, so this subpath and the root `ArtifactStore` namespace keep
15
+ * their whole surface.
16
+ *
17
+ * @since 1.0.0-rc.0
18
+ */
19
+
20
+ import { Sha256 } from "@smthrs/crypto"
21
+ import * as Context from "effect/Context"
22
+ import type * as Crypto from "effect/Crypto"
23
+ import * as Effect from "effect/Effect"
24
+ import * as FileSystem from "effect/FileSystem"
25
+ import * as Layer from "effect/Layer"
26
+ import * as Path from "effect/Path"
27
+ import type { ArtifactCorruption, ArtifactMissing, ArtifactStoreError } from "./ArtifactStoreError.ts"
28
+ import { type FileSystemOptions, makeFileSystem } from "./FileSystemArtifactStore.ts"
29
+ import { makeMemory } from "./MemoryArtifactStore.ts"
30
+ import { makeNoop } from "./NoopArtifactStore.ts"
31
+
32
+ export {
33
+ ArtifactCorruption,
34
+ ArtifactMissing,
35
+ ArtifactStoreError,
36
+ ArtifactStoreErrorCode
37
+ } from "./ArtifactStoreError.ts"
38
+ export { defaultDirectory, type FileSystemOptions, makeFileSystem } from "./FileSystemArtifactStore.ts"
39
+ export { measureBytes } from "./measureBytes.ts"
40
+ export { makeMemory } from "./MemoryArtifactStore.ts"
41
+ export { makeNoop } from "./NoopArtifactStore.ts"
42
+ export { snapshotBytes } from "./snapshotBytes.ts"
43
+ export { validateDigest } from "./validateDigest.ts"
44
+
45
+ /**
46
+ * Schema for a content address: exactly 64 lowercase hexadecimal SHA-256
47
+ * characters, branded by `@smthrs/crypto`. Re-exported so a consumer never has
48
+ * to reach past this package for the address type it stores under.
49
+ *
50
+ * @category schemas
51
+ * @since 1.0.0-rc.0
52
+ * @slop
53
+ */
54
+ export const Digest = Sha256.Digest
55
+
56
+ /**
57
+ * A content address produced by this store.
58
+ *
59
+ * Read operations accept a plain `string` rather than this brand on purpose: a
60
+ * digest read back out of a durable row is untrusted input, so the store
61
+ * validates it (see {@link ArtifactStoreError}'s `invalid_digest` code) instead
62
+ * of asking every caller to re-brand a persisted column. `put` returns the
63
+ * brand, because it measured the bytes itself.
64
+ *
65
+ * @category models
66
+ * @since 1.0.0-rc.0
67
+ * @slop
68
+ */
69
+ export type Digest = typeof Sha256.Digest.Type
70
+
71
+ /**
72
+ * Content-addressed blob storage.
73
+ *
74
+ * The contract's ergonomics follow Effect's own `KeyValueStore`
75
+ * (`effect/persistence/KeyValueStore`): a small set of total
76
+ * operations over one address space, with a single typed error family, so a
77
+ * memory, filesystem, or network implementation is the same shape.
78
+ * `findMissing` is Bazel's `MissingDigestsFinder` — one batched round trip
79
+ * whose result is guaranteed to be a subset of its input — because a
80
+ * per-digest existence probe over a network tier is the wrong shape entirely.
81
+ *
82
+ * @category models
83
+ * @since 1.0.0-rc.0
84
+ * @slop
85
+ */
86
+ export interface Service {
87
+ /**
88
+ * Stores `bytes` under their own SHA-256 digest and returns that address.
89
+ * Storing the same bytes twice is idempotent.
90
+ */
91
+ readonly put: (bytes: Uint8Array) => Effect.Effect<Digest, ArtifactStoreError, Crypto.Crypto>
92
+ /**
93
+ * Reads the bytes stored at `digest`, verifying that they still hash to it.
94
+ */
95
+ readonly get: (
96
+ digest: string
97
+ ) => Effect.Effect<Uint8Array, ArtifactMissing | ArtifactCorruption | ArtifactStoreError, Crypto.Crypto>
98
+ /** Whether this tier holds an artifact at `digest`. */
99
+ readonly has: (digest: string) => Effect.Effect<boolean, ArtifactStoreError>
100
+ /**
101
+ * Which of `digests` this tier does not hold. The returned array is
102
+ * guaranteed to be a subset of the input and free of duplicates.
103
+ */
104
+ readonly findMissing: (
105
+ digests: Iterable<string>
106
+ ) => Effect.Effect<Array<string>, ArtifactStoreError>
107
+ }
108
+
109
+ /**
110
+ * Service tag for the content-addressed artifact store.
111
+ *
112
+ * The identity string equals this module's package path, per the house rule
113
+ * that an identity is the defining module path.
114
+ *
115
+ * @category services
116
+ * @since 1.0.0-rc.0
117
+ * @slop
118
+ */
119
+ export class ArtifactStore extends Context.Service<ArtifactStore, Service>()("@smthrs/artifacts/ArtifactStore") {}
120
+
121
+ /**
122
+ * Provides the filesystem-backed artifact store.
123
+ *
124
+ * @category layers
125
+ * @since 1.0.0-rc.0
126
+ * @slop
127
+ */
128
+ export const layerFileSystem = (
129
+ options: FileSystemOptions = {}
130
+ ): Layer.Layer<ArtifactStore, never, FileSystem.FileSystem | Path.Path> =>
131
+ Layer.effect(ArtifactStore)(Effect.gen(function*() {
132
+ return makeFileSystem(yield* FileSystem.FileSystem, yield* Path.Path, options)
133
+ }))
134
+
135
+ /**
136
+ * Provides an in-memory artifact store.
137
+ *
138
+ * @category layers
139
+ * @since 1.0.0-rc.0
140
+ * @slop
141
+ */
142
+ export const layerMemory: Layer.Layer<ArtifactStore> = Layer.effect(ArtifactStore)(Effect.sync(makeMemory))
143
+
144
+ /**
145
+ * Provides a no-op artifact store.
146
+ *
147
+ * @category layers
148
+ * @since 1.0.0-rc.0
149
+ * @slop
150
+ */
151
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<ArtifactStore> =>
152
+ Layer.succeed(ArtifactStore)(makeNoop(overrides))
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The artifact store's typed failures: a refusal of the store itself, the
3
+ * typed miss, and a digest mismatch, with the stable codes a refusal carries.
4
+ *
5
+ * @since 1.0.0-rc.0
6
+ */
7
+
8
+ import { Sha256 } from "@smthrs/crypto"
9
+ import * as Schema from "effect/Schema"
10
+
11
+ /**
12
+ * Stable error codes returned by artifact store operations.
13
+ *
14
+ * @category models
15
+ * @since 1.0.0-rc.0
16
+ * @slop
17
+ */
18
+ export const ArtifactStoreErrorCode = Schema.Literals([
19
+ "digest_failed",
20
+ "invalid_configuration",
21
+ "invalid_digest",
22
+ "unavailable",
23
+ "transport_failed"
24
+ ])
25
+
26
+ /**
27
+ * Stable error codes returned by artifact store operations.
28
+ *
29
+ * @category models
30
+ * @since 1.0.0-rc.0
31
+ * @slop
32
+ */
33
+ export type ArtifactStoreErrorCode = typeof ArtifactStoreErrorCode.Type
34
+
35
+ /**
36
+ * A typed failure of the store itself: the host or crypto provider refused an
37
+ * operation, the remote tier refused a request, or the caller supplied invalid
38
+ * configuration or an invalid content address.
39
+ *
40
+ * Distinct from {@link ArtifactMissing} and {@link ArtifactCorruption} on
41
+ * purpose. A miss is an ordinary, expected outcome that a second tier may
42
+ * still satisfy; corruption is an integrity violation of the store's strongest
43
+ * invariant. `invalid_configuration` and `invalid_digest` are permanent;
44
+ * retryability of host, crypto, and transport failures depends on the cause.
45
+ *
46
+ * @category errors
47
+ * @since 1.0.0-rc.0
48
+ * @slop
49
+ */
50
+ export class ArtifactStoreError extends Schema.TaggedError<ArtifactStoreError>()(
51
+ "@smthrs/artifacts/ArtifactStoreError",
52
+ {
53
+ code: ArtifactStoreErrorCode,
54
+ message: Schema.String,
55
+ cause: Schema.optional(Schema.Unknown)
56
+ }
57
+ ) {}
58
+
59
+ /**
60
+ * The typed miss: this tier holds no bytes at the requested address.
61
+ *
62
+ * A miss is not a failure of the store — it is the answer a read-through
63
+ * composition is built to act on, so it is a distinct tag rather than an
64
+ * `unavailable` code that a caller would have to string-match.
65
+ *
66
+ * @category errors
67
+ * @since 1.0.0-rc.0
68
+ * @slop
69
+ */
70
+ export class ArtifactMissing extends Schema.TaggedError<ArtifactMissing>()(
71
+ "@smthrs/artifacts/ArtifactMissing",
72
+ {
73
+ code: Schema.Literal("artifact_missing"),
74
+ digest: Sha256.Digest
75
+ }
76
+ ) {}
77
+
78
+ /**
79
+ * Bytes stored at a content address no longer hash to it.
80
+ *
81
+ * Every read is digest-verified, so a truncated blob left by a crashing writer
82
+ * or by disk corruption is refused rather than handed back as if it were the
83
+ * recorded artifact.
84
+ *
85
+ * @category errors
86
+ * @since 1.0.0-rc.0
87
+ * @slop
88
+ */
89
+ export class ArtifactCorruption extends Schema.TaggedError<ArtifactCorruption>()(
90
+ "@smthrs/artifacts/ArtifactCorruption",
91
+ {
92
+ code: Schema.Literal("artifact_corruption"),
93
+ recordedDigest: Sha256.Digest,
94
+ measuredDigest: Sha256.Digest
95
+ }
96
+ ) {}