@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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/cjs/ArtifactBackupLease.d.ts +34 -0
- package/dist/cjs/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/cjs/ArtifactBackupLease.js +175 -0
- package/dist/cjs/ArtifactBackupLease.js.map +7 -0
- package/dist/cjs/ArtifactStore.d.ts +129 -0
- package/dist/cjs/ArtifactStore.d.ts.map +1 -0
- package/dist/cjs/ArtifactStore.js +73 -0
- package/dist/cjs/ArtifactStore.js.map +7 -0
- package/dist/cjs/ArtifactStoreError.d.ts +82 -0
- package/dist/cjs/ArtifactStoreError.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreError.js +72 -0
- package/dist/cjs/ArtifactStoreError.js.map +7 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/cjs/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/cjs/ArtifactStoreMetrics.js +51 -0
- package/dist/cjs/ArtifactStoreMetrics.js.map +7 -0
- package/dist/cjs/ArtifactSweep.d.ts +185 -0
- package/dist/cjs/ArtifactSweep.d.ts.map +1 -0
- package/dist/cjs/ArtifactSweep.js +163 -0
- package/dist/cjs/ArtifactSweep.js.map +7 -0
- package/dist/cjs/CombinedArtifacts.d.ts +103 -0
- package/dist/cjs/CombinedArtifacts.d.ts.map +1 -0
- package/dist/cjs/CombinedArtifacts.js +189 -0
- package/dist/cjs/CombinedArtifacts.js.map +7 -0
- package/dist/cjs/FileLease.d.ts +113 -0
- package/dist/cjs/FileLease.d.ts.map +1 -0
- package/dist/cjs/FileLease.js +201 -0
- package/dist/cjs/FileLease.js.map +7 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts +93 -0
- package/dist/cjs/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/cjs/FileSystemArtifactStore.js +276 -0
- package/dist/cjs/FileSystemArtifactStore.js.map +7 -0
- package/dist/cjs/MemoryArtifactStore.d.ts +25 -0
- package/dist/cjs/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/cjs/MemoryArtifactStore.js +84 -0
- package/dist/cjs/MemoryArtifactStore.js.map +7 -0
- package/dist/cjs/NoopArtifactStore.d.ts +17 -0
- package/dist/cjs/NoopArtifactStore.d.ts.map +1 -0
- package/dist/cjs/NoopArtifactStore.js +46 -0
- package/dist/cjs/NoopArtifactStore.js.map +7 -0
- package/dist/cjs/RemoteArtifacts.d.ts +237 -0
- package/dist/cjs/RemoteArtifacts.d.ts.map +1 -0
- package/dist/cjs/RemoteArtifacts.js +405 -0
- package/dist/cjs/RemoteArtifacts.js.map +7 -0
- package/dist/cjs/index.d.ts +65 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +47 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts +30 -0
- package/dist/cjs/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactLocks.js +93 -0
- package/dist/cjs/internal/ArtifactLocks.js.map +7 -0
- package/dist/cjs/internal/ArtifactPath.d.ts +19 -0
- package/dist/cjs/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/cjs/internal/ArtifactPath.js +74 -0
- package/dist/cjs/internal/ArtifactPath.js.map +7 -0
- package/dist/cjs/measureBytes.d.ts +18 -0
- package/dist/cjs/measureBytes.d.ts.map +1 -0
- package/dist/cjs/measureBytes.js +43 -0
- package/dist/cjs/measureBytes.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/snapshotBytes.d.ts +19 -0
- package/dist/cjs/snapshotBytes.d.ts.map +1 -0
- package/dist/cjs/snapshotBytes.js +41 -0
- package/dist/cjs/snapshotBytes.js.map +7 -0
- package/dist/cjs/validateDigest.d.ts +22 -0
- package/dist/cjs/validateDigest.d.ts.map +1 -0
- package/dist/cjs/validateDigest.js +42 -0
- package/dist/cjs/validateDigest.js.map +7 -0
- package/dist/esm/ArtifactBackupLease.d.ts +34 -0
- package/dist/esm/ArtifactBackupLease.d.ts.map +1 -0
- package/dist/esm/ArtifactBackupLease.js +117 -0
- package/dist/esm/ArtifactBackupLease.js.map +1 -0
- package/dist/esm/ArtifactStore.d.ts +129 -0
- package/dist/esm/ArtifactStore.d.ts.map +1 -0
- package/dist/esm/ArtifactStore.js +83 -0
- package/dist/esm/ArtifactStore.js.map +1 -0
- package/dist/esm/ArtifactStoreError.d.ts +82 -0
- package/dist/esm/ArtifactStoreError.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreError.js +77 -0
- package/dist/esm/ArtifactStoreError.js.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts +70 -0
- package/dist/esm/ArtifactStoreMetrics.d.ts.map +1 -0
- package/dist/esm/ArtifactStoreMetrics.js +77 -0
- package/dist/esm/ArtifactStoreMetrics.js.map +1 -0
- package/dist/esm/ArtifactSweep.d.ts +185 -0
- package/dist/esm/ArtifactSweep.d.ts.map +1 -0
- package/dist/esm/ArtifactSweep.js +202 -0
- package/dist/esm/ArtifactSweep.js.map +1 -0
- package/dist/esm/CombinedArtifacts.d.ts +103 -0
- package/dist/esm/CombinedArtifacts.d.ts.map +1 -0
- package/dist/esm/CombinedArtifacts.js +214 -0
- package/dist/esm/CombinedArtifacts.js.map +1 -0
- package/dist/esm/FileLease.d.ts +113 -0
- package/dist/esm/FileLease.d.ts.map +1 -0
- package/dist/esm/FileLease.js +239 -0
- package/dist/esm/FileLease.js.map +1 -0
- package/dist/esm/FileSystemArtifactStore.d.ts +93 -0
- package/dist/esm/FileSystemArtifactStore.d.ts.map +1 -0
- package/dist/esm/FileSystemArtifactStore.js +336 -0
- package/dist/esm/FileSystemArtifactStore.js.map +1 -0
- package/dist/esm/MemoryArtifactStore.d.ts +25 -0
- package/dist/esm/MemoryArtifactStore.d.ts.map +1 -0
- package/dist/esm/MemoryArtifactStore.js +67 -0
- package/dist/esm/MemoryArtifactStore.js.map +1 -0
- package/dist/esm/NoopArtifactStore.d.ts +17 -0
- package/dist/esm/NoopArtifactStore.d.ts.map +1 -0
- package/dist/esm/NoopArtifactStore.js +27 -0
- package/dist/esm/NoopArtifactStore.js.map +1 -0
- package/dist/esm/RemoteArtifacts.d.ts +237 -0
- package/dist/esm/RemoteArtifacts.d.ts.map +1 -0
- package/dist/esm/RemoteArtifacts.js +554 -0
- package/dist/esm/RemoteArtifacts.js.map +1 -0
- package/dist/esm/index.d.ts +65 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ArtifactLocks.d.ts +30 -0
- package/dist/esm/internal/ArtifactLocks.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactLocks.js +86 -0
- package/dist/esm/internal/ArtifactLocks.js.map +1 -0
- package/dist/esm/internal/ArtifactPath.d.ts +19 -0
- package/dist/esm/internal/ArtifactPath.d.ts.map +1 -0
- package/dist/esm/internal/ArtifactPath.js +59 -0
- package/dist/esm/internal/ArtifactPath.js.map +1 -0
- package/dist/esm/measureBytes.d.ts +18 -0
- package/dist/esm/measureBytes.d.ts.map +1 -0
- package/dist/esm/measureBytes.js +22 -0
- package/dist/esm/measureBytes.js.map +1 -0
- package/dist/esm/snapshotBytes.d.ts +19 -0
- package/dist/esm/snapshotBytes.d.ts.map +1 -0
- package/dist/esm/snapshotBytes.js +30 -0
- package/dist/esm/snapshotBytes.js.map +1 -0
- package/dist/esm/validateDigest.d.ts +22 -0
- package/dist/esm/validateDigest.d.ts.map +1 -0
- package/dist/esm/validateDigest.js +26 -0
- package/dist/esm/validateDigest.js.map +1 -0
- package/docs/README.md +200 -0
- package/docs/api.md +474 -0
- package/docs/concepts/content-addressing.md +174 -0
- package/docs/concepts/coordination.md +141 -0
- package/docs/concepts/tiers.md +147 -0
- package/docs/guides/fence-a-backup.md +123 -0
- package/docs/guides/reclaim-disk-space.md +143 -0
- package/docs/guides/serve-the-artifact-protocol.md +140 -0
- package/docs/guides/share-artifacts-across-machines.md +180 -0
- package/docs/guides/test-against-an-artifact-store.md +158 -0
- package/docs/installation.md +98 -0
- package/docs/quickstart.md +182 -0
- package/docs/troubleshooting.md +232 -0
- package/package.json +179 -3
- package/src/ArtifactBackupLease.ts +213 -0
- package/src/ArtifactStore.ts +152 -0
- package/src/ArtifactStoreError.ts +96 -0
- package/src/ArtifactStoreMetrics.ts +83 -0
- package/src/ArtifactSweep.ts +336 -0
- package/src/CombinedArtifacts.ts +369 -0
- package/src/FileLease.ts +380 -0
- package/src/FileSystemArtifactStore.ts +459 -0
- package/src/MemoryArtifactStore.ts +83 -0
- package/src/NoopArtifactStore.ts +30 -0
- package/src/RemoteArtifacts.ts +809 -0
- package/src/index.ts +71 -0
- package/src/internal/ArtifactLocks.ts +110 -0
- package/src/internal/ArtifactPath.ts +72 -0
- package/src/measureBytes.ts +28 -0
- package/src/snapshotBytes.ts +34 -0
- package/src/validateDigest.ts +31 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content-addressed artifact store: bytes addressed by their own digest.
|
|
3
|
+
*
|
|
4
|
+
* `ArtifactStore` is the byte half of the cache. `@smthrs/step-cache` maps a
|
|
5
|
+
* step key to a recorded result, while large result bytes live here under their
|
|
6
|
+
* digest. The package depends on `effect` and `@smthrs/crypto`, owns no SQL,
|
|
7
|
+
* and bundles for the browser: host access is Effect's `FileSystem` and
|
|
8
|
+
* `HttpClient` tags, both of which the capability kernel decorates in place.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { ArtifactStore, CombinedArtifacts, RemoteArtifacts } from "@smthrs/artifacts"
|
|
12
|
+
* import * as Effect from "effect/Effect"
|
|
13
|
+
* import * as FileSystem from "effect/FileSystem"
|
|
14
|
+
* import * as Path from "effect/Path"
|
|
15
|
+
*
|
|
16
|
+
* const layer = CombinedArtifacts.layer({
|
|
17
|
+
* local: Effect.map(Effect.all([FileSystem.FileSystem, Path.Path]), ([fs, path]) =>
|
|
18
|
+
* ArtifactStore.makeFileSystem(fs, path)),
|
|
19
|
+
* remote: RemoteArtifacts.make({ endpoint: "https://cache.example.com" })
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @since 1.0.0-rc.0
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @category services
|
|
28
|
+
* @since 1.0.0-rc.0
|
|
29
|
+
* @slop
|
|
30
|
+
*/
|
|
31
|
+
export * as ArtifactStore from "./ArtifactStore.ts"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @category metrics
|
|
35
|
+
* @since 1.0.0-rc.0
|
|
36
|
+
* @slop
|
|
37
|
+
*/
|
|
38
|
+
export * as ArtifactStoreMetrics from "./ArtifactStoreMetrics.ts"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @category services
|
|
42
|
+
* @since 1.0.0-rc.0
|
|
43
|
+
*/
|
|
44
|
+
export * as ArtifactBackupLease from "./ArtifactBackupLease.ts"
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @category services
|
|
48
|
+
* @since 1.0.0-rc.0
|
|
49
|
+
* @slop
|
|
50
|
+
*/
|
|
51
|
+
export * as ArtifactSweep from "./ArtifactSweep.ts"
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @category services
|
|
55
|
+
* @since 1.0.0-rc.0
|
|
56
|
+
* @slop
|
|
57
|
+
*/
|
|
58
|
+
export * as CombinedArtifacts from "./CombinedArtifacts.ts"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @category coordination
|
|
62
|
+
* @since 1.0.0-rc.1
|
|
63
|
+
*/
|
|
64
|
+
export * as FileLease from "./FileLease.ts"
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @category services
|
|
68
|
+
* @since 1.0.0-rc.0
|
|
69
|
+
* @slop
|
|
70
|
+
*/
|
|
71
|
+
export * as RemoteArtifacts from "./RemoteArtifacts.ts"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-digest coordination for filesystem publication and removal.
|
|
3
|
+
*
|
|
4
|
+
* The semaphore is the cheap in-process path. A `FileLease` lock file is the
|
|
5
|
+
* actual workspace-wide fence: every process that can mutate the object
|
|
6
|
+
* directory observes it, and a crashed owner is recovered after its heartbeat
|
|
7
|
+
* expires.
|
|
8
|
+
*
|
|
9
|
+
* @since 1.0.0-rc.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as Deferred from "effect/Deferred"
|
|
13
|
+
import * as Effect from "effect/Effect"
|
|
14
|
+
import type * as FileSystem from "effect/FileSystem"
|
|
15
|
+
import * as Semaphore from "effect/Semaphore"
|
|
16
|
+
import * as FileLease from "../FileLease.ts"
|
|
17
|
+
import * as ArtifactPath from "./ArtifactPath.ts"
|
|
18
|
+
|
|
19
|
+
interface Entry {
|
|
20
|
+
readonly semaphore: Semaphore.Semaphore
|
|
21
|
+
users: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const locks = new WeakMap<FileSystem.FileSystem, Map<string, Entry>>()
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The subdirectory of the objects directory that holds lock files and their
|
|
28
|
+
* stale-owner tombstones. Named here rather than spelled in each caller so the
|
|
29
|
+
* store's crash-orphan sweep reclaims exactly what this module creates.
|
|
30
|
+
*
|
|
31
|
+
* @category constants
|
|
32
|
+
* @since 1.0.0-rc.0
|
|
33
|
+
*/
|
|
34
|
+
export const directoryName = ".locks"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Coordinates publication, freshening, and sweep deletion for one digest.
|
|
38
|
+
* `process` is the explicit weaker mode for hosts without atomic create.
|
|
39
|
+
*
|
|
40
|
+
* @category combinators
|
|
41
|
+
* @since 1.0.0-rc.0
|
|
42
|
+
*/
|
|
43
|
+
export const withDigest = <A, E, R, E2>(
|
|
44
|
+
fs: FileSystem.FileSystem,
|
|
45
|
+
directory: string,
|
|
46
|
+
digest: string,
|
|
47
|
+
effect: Effect.Effect<A, E, R>,
|
|
48
|
+
failure: (cause: unknown) => E2,
|
|
49
|
+
coordination: "required" | "process" = "required"
|
|
50
|
+
): Effect.Effect<A, E | E2, R> =>
|
|
51
|
+
// Every execution of the returned effect claims its own share of the entry,
|
|
52
|
+
// which is why the whole body is suspended rather than run when `withDigest`
|
|
53
|
+
// is called. A count taken at construction is wrong in both directions: an
|
|
54
|
+
// effect built and discarded pins an entry nothing will ever release, and one
|
|
55
|
+
// built once and run repeatedly — `ArtifactBackupLease` builds its gate once
|
|
56
|
+
// and runs it on every heartbeat — retires the entry underneath a live holder,
|
|
57
|
+
// so the next caller mints a second semaphore for the same digest and the two
|
|
58
|
+
// serialize against nothing.
|
|
59
|
+
Effect.suspend(() => {
|
|
60
|
+
let byKey = locks.get(fs)
|
|
61
|
+
if (byKey === undefined) {
|
|
62
|
+
byKey = new Map()
|
|
63
|
+
locks.set(fs, byKey)
|
|
64
|
+
}
|
|
65
|
+
const key = JSON.stringify([directory, digest])
|
|
66
|
+
let entry = byKey.get(key)
|
|
67
|
+
if (entry === undefined) {
|
|
68
|
+
entry = { semaphore: Semaphore.makeUnsafe(1), users: 0 }
|
|
69
|
+
byKey.set(key, entry)
|
|
70
|
+
}
|
|
71
|
+
entry.users += 1
|
|
72
|
+
const held = entry
|
|
73
|
+
const table = byKey
|
|
74
|
+
|
|
75
|
+
// The deadline starts before the semaphore wait and ends once both locks
|
|
76
|
+
// are held. The protected operation and its release are never timed out.
|
|
77
|
+
const ready = Deferred.makeUnsafe<void>()
|
|
78
|
+
const deadline = Deferred.await(ready).pipe(
|
|
79
|
+
Effect.timeout(FileLease.defaultAcquireWithin),
|
|
80
|
+
Effect.catchTag("TimeoutError", (cause) => Effect.fail(failure(cause))),
|
|
81
|
+
Effect.andThen(Effect.never)
|
|
82
|
+
)
|
|
83
|
+
const coordinated = coordination === "process"
|
|
84
|
+
? Deferred.succeed(ready, undefined).pipe(Effect.andThen(effect))
|
|
85
|
+
: Effect.gen(function*() {
|
|
86
|
+
// The lock directory is refused as a symlink before and after it is
|
|
87
|
+
// created, and its identity is re-checked before every lock-file
|
|
88
|
+
// mutation, so a swap of `.locks` for a link cannot redirect lock,
|
|
89
|
+
// claim, or tombstone writes and removals elsewhere. The portable
|
|
90
|
+
// filesystem cannot make a check atomic with the mutation after it.
|
|
91
|
+
const lockDirectory = `${directory}/${directoryName}`
|
|
92
|
+
yield* ArtifactPath.guard(fs, lockDirectory).pipe(Effect.mapError(failure))
|
|
93
|
+
yield* fs.makeDirectory(lockDirectory, { recursive: true, mode: 0o700 }).pipe(Effect.mapError(failure))
|
|
94
|
+
const checkLockDirectory = yield* ArtifactPath.guard(fs, lockDirectory).pipe(Effect.mapError(failure))
|
|
95
|
+
return yield* FileLease.hold(fs, `${lockDirectory}/${digest}.lock`, effect, failure, {
|
|
96
|
+
label: "Artifact lock",
|
|
97
|
+
annotations: { digest },
|
|
98
|
+
onAcquired: Deferred.succeed(ready, undefined).pipe(Effect.asVoid),
|
|
99
|
+
guard: checkLockDirectory
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return held.semaphore.withPermit(coordinated).pipe(
|
|
104
|
+
Effect.raceFirst(deadline),
|
|
105
|
+
Effect.ensuring(Effect.sync(() => {
|
|
106
|
+
held.users -= 1
|
|
107
|
+
if (held.users === 0 && table.get(key) === held) table.delete(key)
|
|
108
|
+
}))
|
|
109
|
+
)
|
|
110
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path checks for the portable filesystem adapter. These detect symlinks and
|
|
3
|
+
* observed replacements, but cannot make pathname mutations atomic with the
|
|
4
|
+
* check. Effect's FileSystem has no descriptor-relative rename or unlink.
|
|
5
|
+
*
|
|
6
|
+
* @since 1.0.0-rc.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import type * as FileSystem from "effect/FileSystem"
|
|
11
|
+
import * as Option from "effect/Option"
|
|
12
|
+
import * as PlatformError from "effect/PlatformError"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Checks one entry without accepting a symbolic link and returns a check that
|
|
16
|
+
* also refuses a changed identity. Missing entries are allowed for creation.
|
|
17
|
+
*
|
|
18
|
+
* @category utilities
|
|
19
|
+
* @since 1.0.0-rc.0
|
|
20
|
+
*/
|
|
21
|
+
export const guard = (
|
|
22
|
+
fs: FileSystem.FileSystem,
|
|
23
|
+
path: string,
|
|
24
|
+
type: "Directory" | "File" = "Directory",
|
|
25
|
+
expected?: FileSystem.File.Info
|
|
26
|
+
): Effect.Effect<Effect.Effect<void, PlatformError.PlatformError>, PlatformError.PlatformError> => {
|
|
27
|
+
const refused = () =>
|
|
28
|
+
PlatformError.systemError({
|
|
29
|
+
_tag: "PermissionDenied",
|
|
30
|
+
module: "ArtifactPath",
|
|
31
|
+
method: "guard",
|
|
32
|
+
pathOrDescriptor: path,
|
|
33
|
+
description: "artifact path is a symlink, has the wrong type, or was replaced"
|
|
34
|
+
})
|
|
35
|
+
const inspect = Effect.gen(function*() {
|
|
36
|
+
const linked = yield* fs.readLink(path).pipe(
|
|
37
|
+
Effect.as(true),
|
|
38
|
+
Effect.catch((cause) => {
|
|
39
|
+
// Node readlink uses EINVAL for an existing entry that is not a link.
|
|
40
|
+
// Other failures, including an unsupported host capability, fail closed.
|
|
41
|
+
const underlying = cause.cause
|
|
42
|
+
return cause.reason._tag === "NotFound" ||
|
|
43
|
+
(typeof underlying === "object" && underlying !== null && "code" in underlying &&
|
|
44
|
+
underlying.code === "EINVAL")
|
|
45
|
+
? Effect.succeed(false)
|
|
46
|
+
: Effect.fail(cause)
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
if (linked) return yield* Effect.fail(refused())
|
|
50
|
+
const info = yield* fs.stat(path).pipe(
|
|
51
|
+
Effect.map(Option.some),
|
|
52
|
+
Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(cause))
|
|
53
|
+
)
|
|
54
|
+
if (Option.isSome(info) && info.value.type !== type) return yield* Effect.fail(refused())
|
|
55
|
+
return info
|
|
56
|
+
})
|
|
57
|
+
const unchanged = (before: Option.Option<FileSystem.File.Info>, after: Option.Option<FileSystem.File.Info>) => {
|
|
58
|
+
if (Option.isNone(before)) return Effect.void
|
|
59
|
+
if (
|
|
60
|
+
Option.isNone(after) || before.value.dev !== after.value.dev ||
|
|
61
|
+
Option.getOrUndefined(before.value.ino) !== Option.getOrUndefined(after.value.ino)
|
|
62
|
+
) {
|
|
63
|
+
return Effect.fail(refused())
|
|
64
|
+
}
|
|
65
|
+
return Effect.void
|
|
66
|
+
}
|
|
67
|
+
return Effect.gen(function*() {
|
|
68
|
+
const before = yield* inspect
|
|
69
|
+
yield* unchanged(Option.fromUndefinedOr(expected), before)
|
|
70
|
+
return Effect.flatMap(inspect, (after) => unchanged(before, after))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measuring artifact bytes: the SHA-256 content address of one immutable
|
|
3
|
+
* snapshot, computed through the injected `Crypto` service.
|
|
4
|
+
*
|
|
5
|
+
* @since 1.0.0-rc.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Sha256 } from "@smthrs/crypto"
|
|
9
|
+
import type * as Crypto from "effect/Crypto"
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import type { Digest } from "./ArtifactStore.ts"
|
|
12
|
+
import { ArtifactStoreError } from "./ArtifactStoreError.ts"
|
|
13
|
+
|
|
14
|
+
const digestFailure = (cause: unknown): ArtifactStoreError =>
|
|
15
|
+
new ArtifactStoreError({
|
|
16
|
+
code: "digest_failed",
|
|
17
|
+
message: "the Crypto service failed to compute an artifact digest",
|
|
18
|
+
cause
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Measures one immutable byte snapshot without ever retaining it in an error.
|
|
23
|
+
*
|
|
24
|
+
* @category utilities
|
|
25
|
+
* @since 1.0.0-rc.0
|
|
26
|
+
*/
|
|
27
|
+
export const measureBytes = (bytes: Uint8Array): Effect.Effect<Digest, ArtifactStoreError, Crypto.Crypto> =>
|
|
28
|
+
Sha256.digest(bytes).pipe(Effect.mapError(digestFailure))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshotting caller-owned artifact bytes, so a caller that reuses its buffer
|
|
3
|
+
* cannot change what is measured or stored.
|
|
4
|
+
*
|
|
5
|
+
* @since 1.0.0-rc.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import { ArtifactStoreError } from "./ArtifactStoreError.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A refused byte copy is the host declining an allocation, not a crypto
|
|
13
|
+
* failure: the Crypto service has not been consulted when this fires, so
|
|
14
|
+
* reporting `digest_failed` would point an operator diagnosing memory pressure
|
|
15
|
+
* at the wrong subsystem. The message is constant, and the buffer is never
|
|
16
|
+
* attached.
|
|
17
|
+
*/
|
|
18
|
+
const snapshotFailure = (cause: unknown): ArtifactStoreError =>
|
|
19
|
+
new ArtifactStoreError({ code: "unavailable", message: "the host could not copy the artifact bytes", cause })
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Copies a caller-owned buffer when the returned Effect begins.
|
|
23
|
+
*
|
|
24
|
+
* A host that refuses the copy — a detached buffer, an allocation past what the
|
|
25
|
+
* runtime will give — fails as `unavailable`, the code for a host refusal.
|
|
26
|
+
*
|
|
27
|
+
* @category utilities
|
|
28
|
+
* @since 1.0.0-rc.0
|
|
29
|
+
*/
|
|
30
|
+
export const snapshotBytes = (bytes: Uint8Array): Effect.Effect<Uint8Array, ArtifactStoreError> =>
|
|
31
|
+
Effect.try({
|
|
32
|
+
try: () => new Uint8Array(bytes),
|
|
33
|
+
catch: (cause) => snapshotFailure(cause)
|
|
34
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validating a content address before any implementation logs it or
|
|
3
|
+
* interpolates it into a path or URL.
|
|
4
|
+
*
|
|
5
|
+
* @since 1.0.0-rc.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import type { Digest } from "./ArtifactStore.ts"
|
|
10
|
+
import { ArtifactStoreError } from "./ArtifactStoreError.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Refuses anything other than the canonical SHA-256 address representation.
|
|
14
|
+
*
|
|
15
|
+
* Every implementation validates before logging or interpolating an untrusted
|
|
16
|
+
* value into a path or URL. The failure text is constant and bounded, so even a
|
|
17
|
+
* hostile multi-megabyte value cannot be copied into logs or durable errors.
|
|
18
|
+
*
|
|
19
|
+
* @category predicates
|
|
20
|
+
* @since 1.0.0-rc.0
|
|
21
|
+
* @slop
|
|
22
|
+
*/
|
|
23
|
+
export const validateDigest = (digest: string): Effect.Effect<Digest, ArtifactStoreError> =>
|
|
24
|
+
typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)
|
|
25
|
+
? Effect.succeed(digest as Digest)
|
|
26
|
+
: Effect.fail(
|
|
27
|
+
new ArtifactStoreError({
|
|
28
|
+
code: "invalid_digest",
|
|
29
|
+
message: "artifact digest must be exactly 64 lowercase hexadecimal characters"
|
|
30
|
+
})
|
|
31
|
+
)
|