@smthrs/engine-store 1.0.0-rc.6 → 1.0.0-rc.8
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/README.md +13 -11
- package/dist/cjs/ArtifactSync.d.ts.map +1 -1
- package/dist/cjs/ArtifactSync.js +5 -0
- package/dist/cjs/ArtifactSync.js.map +2 -2
- package/dist/cjs/DisasterRecovery.d.ts +9 -26
- package/dist/cjs/DisasterRecovery.d.ts.map +1 -1
- package/dist/cjs/DisasterRecovery.js +27 -18
- package/dist/cjs/DisasterRecovery.js.map +2 -2
- package/dist/cjs/StepBoundary.d.ts +8 -6
- package/dist/cjs/StepBoundary.d.ts.map +1 -1
- package/dist/cjs/StepBoundary.js +10 -7
- package/dist/cjs/StepBoundary.js.map +2 -2
- package/dist/cjs/index.d.ts +12 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/internal/ActionPersistence.d.ts.map +1 -1
- package/dist/cjs/internal/ActionPersistence.js +27 -2
- package/dist/cjs/internal/ActionPersistence.js.map +3 -3
- package/dist/esm/ArtifactSync.d.ts.map +1 -1
- package/dist/esm/ArtifactSync.js +6 -1
- package/dist/esm/ArtifactSync.js.map +1 -1
- package/dist/esm/DisasterRecovery.d.ts +9 -26
- package/dist/esm/DisasterRecovery.d.ts.map +1 -1
- package/dist/esm/DisasterRecovery.js +46 -27
- package/dist/esm/DisasterRecovery.js.map +1 -1
- package/dist/esm/StepBoundary.d.ts +8 -6
- package/dist/esm/StepBoundary.d.ts.map +1 -1
- package/dist/esm/StepBoundary.js +17 -8
- package/dist/esm/StepBoundary.js.map +1 -1
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal/ActionPersistence.d.ts.map +1 -1
- package/dist/esm/internal/ActionPersistence.js +59 -3
- package/dist/esm/internal/ActionPersistence.js.map +1 -1
- package/docs/README.md +3 -1
- package/docs/api.md +23 -17
- package/docs/concepts/attempts-and-replay.md +15 -2
- package/docs/guides/back-up-and-restore.md +9 -3
- package/docs/guides/reclaim-runs-from-a-dead-host.md +16 -0
- package/docs/installation.md +5 -1
- package/docs/quickstart.md +9 -1
- package/docs/troubleshooting.md +3 -0
- package/package.json +18 -18
- package/src/ArtifactSync.ts +7 -0
- package/src/DisasterRecovery.ts +90 -58
- package/src/StepBoundary.ts +29 -14
- package/src/index.ts +14 -0
- package/src/internal/ActionPersistence.ts +64 -3
package/docs/README.md
CHANGED
|
@@ -11,7 +11,9 @@ over.
|
|
|
11
11
|
It is an [Effect](https://effect.website) library: every surface below is a
|
|
12
12
|
`Layer` you compose or an `Effect` you run.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
```bash
|
|
15
|
+
npm install @smthrs/engine-store@next effect
|
|
16
|
+
```
|
|
15
17
|
|
|
16
18
|
## What it solves
|
|
17
19
|
|
package/docs/api.md
CHANGED
|
@@ -369,16 +369,16 @@ interface Service {
|
|
|
369
369
|
}
|
|
370
370
|
```
|
|
371
371
|
|
|
372
|
-
| Export | Signature | Meaning
|
|
373
|
-
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
374
|
-
| `StepBoundary` | `Context.Service<Service>` | Service tag.
|
|
375
|
-
| `make` | `(service: Service) => Service` | Brands an implementation, so a wrong shape is reported where it is written.
|
|
376
|
-
| `makeFileSystem` | `(fs: FileSystem, path: Path, artifacts: ArtifactStore.Service, options?: FileSystemOptions) => Service` | The production boundary.
|
|
377
|
-
| `layer` | `Layer<Service, never, FileSystem \| Path \| ArtifactStore>` | Provides it,
|
|
378
|
-
| `layerTest` | `(options?: TestOptions) => Layer<Service>` | Deterministic in-memory boundary.
|
|
379
|
-
| `exactReads` | `(descriptor: FileBoundary) => ReadonlyArray<FileInput>` | Exact read inputs, ignoring declarations that still need expansion.
|
|
380
|
-
| `readSetMatches` | `(prepared: PreparedBoundary) => boolean` | Whether the measured snapshot still matches the declaration.
|
|
381
|
-
| `referencedDigests` | `(evidence: BoundaryEvidence) => ReadonlyArray<ArtifactStore.Digest>` | The digests the evidence references rather than inlines. Evidence from a foreign implementation yields the empty list.
|
|
372
|
+
| Export | Signature | Meaning |
|
|
373
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
374
|
+
| `StepBoundary` | `Context.Service<Service>` | Service tag. |
|
|
375
|
+
| `make` | `(service: Service) => Service` | Brands an implementation, so a wrong shape is reported where it is written. |
|
|
376
|
+
| `makeFileSystem` | `(fs: FileSystem, path: Path, artifacts: ArtifactStore.Service, options?: FileSystemOptions) => Service` | The production boundary. |
|
|
377
|
+
| `layer` | `Layer<Service, never, FileSystem \| Path \| ArtifactStore>` | Provides it, rooting measurement and replay at the kernel `Workspace` root when one is in context. Resolves that root with the host `Path`. |
|
|
378
|
+
| `layerTest` | `(options?: TestOptions) => Layer<Service>` | Deterministic in-memory boundary. |
|
|
379
|
+
| `exactReads` | `(descriptor: FileBoundary) => ReadonlyArray<FileInput>` | Exact read inputs, ignoring declarations that still need expansion. |
|
|
380
|
+
| `readSetMatches` | `(prepared: PreparedBoundary) => boolean` | Whether the measured snapshot still matches the declaration. |
|
|
381
|
+
| `referencedDigests` | `(evidence: BoundaryEvidence) => ReadonlyArray<ArtifactStore.Digest>` | The digests the evidence references rather than inlines. Evidence from a foreign implementation yields the empty list. |
|
|
382
382
|
|
|
383
383
|
### Schemas
|
|
384
384
|
|
|
@@ -393,11 +393,11 @@ interface Service {
|
|
|
393
393
|
|
|
394
394
|
### FileSystemOptions
|
|
395
395
|
|
|
396
|
-
| Field | Default | Meaning
|
|
397
|
-
| --------------------- | ------- |
|
|
398
|
-
| `maxInlineBytes` | 1 MiB | The largest single output inlined into the evidence. Anything larger is stored by digest reference.
|
|
399
|
-
| `maxTotalInlineBytes` | 8 MiB | The largest aggregate inline payload one settle may fold in. Past it, an output is spilled even though it individually fits.
|
|
400
|
-
| `root` | cwd | The root replay confines writes and removals to. A filesystem already confined to a root keeps its own.
|
|
396
|
+
| Field | Default | Meaning |
|
|
397
|
+
| --------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
398
|
+
| `maxInlineBytes` | 1 MiB | The largest single output inlined into the evidence. Anything larger is stored by digest reference. |
|
|
399
|
+
| `maxTotalInlineBytes` | 8 MiB | The largest aggregate inline payload one settle may fold in. Past it, an output is spilled even though it individually fits. |
|
|
400
|
+
| `root` | cwd | The root declared paths resolve against: `prepare` and `settle` measure under it, and replay confines writes and removals to it. A filesystem already confined to a root keeps its own. |
|
|
401
401
|
|
|
402
402
|
`replayOutputs` writes and deletes what the evidence names, so it confines its
|
|
403
403
|
own mutations instead of trusting the composition. Every write, removal, probe,
|
|
@@ -406,7 +406,10 @@ one descriptor-relative, no-follow request per call against the pinned root. A
|
|
|
406
406
|
symlink on a replayed path (a parent, the final component, or a member of a
|
|
407
407
|
replayed tree) refuses with `UnsupportedBoundary`, and so does a host with no
|
|
408
408
|
descriptor-relative executor or isolation attestation, before any host call.
|
|
409
|
-
`prepare` and `settle` only read and keep the caller's filesystem
|
|
409
|
+
`prepare` and `settle` only read and keep the caller's filesystem, but hand a
|
|
410
|
+
path-based host each declared path resolved against `root`, the root a step's
|
|
411
|
+
own FileSystem resolves against. A same-named file in the process working
|
|
412
|
+
directory therefore never answers the dirty check (issue #3911).
|
|
410
413
|
|
|
411
414
|
### TestOptions
|
|
412
415
|
|
|
@@ -892,7 +895,10 @@ durable fails and the shared entry is withheld.
|
|
|
892
895
|
|
|
893
896
|
`hydrate(digests)` establishes that this host can resolve every referenced
|
|
894
897
|
artifact and reports whether the replay is now worth retrying. It never fails a
|
|
895
|
-
run: a shared tier that is down must not stop work that can simply be done.
|
|
898
|
+
run: a shared tier that is down must not stop work that can simply be done. A
|
|
899
|
+
downloaded blob counts only when this host's store files it under the requested
|
|
900
|
+
digest, so a shared tier that serves the wrong bytes costs the replay, never
|
|
901
|
+
its correctness.
|
|
896
902
|
|
|
897
903
|
The two tiers cannot both be layers, because they inhabit the same tag and
|
|
898
904
|
composing them would shadow one with the other.
|
|
@@ -30,15 +30,21 @@ question, answered in [Cache admission](./cache-admission.md).
|
|
|
30
30
|
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
31
|
| `succeeded` | Returns the recorded result, and calls `StepBoundary.replayOutputs` first so the workspace holds the outputs the step produced. |
|
|
32
32
|
| `failed` | Rethrows the persisted domain failure. It never re-admits the attempt. |
|
|
33
|
-
| `running` |
|
|
33
|
+
| `running` | Asks the step cache first when the step is cache-eligible, then recovers under the run fence and admission permit. |
|
|
34
34
|
| `suspended` | Continues the same attempt rather than burning a new one against the retry budget. |
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
A settled attempt remains authoritative when a matching shared cache row
|
|
37
37
|
exists; another run's cached success cannot replace its result or failure.
|
|
38
38
|
Failure values were schema-encoded before persistence, so a `_tag` survives
|
|
39
39
|
the JSON round trip and a `RetryPolicy`'s non-retryable matching still applies
|
|
40
40
|
on replay.
|
|
41
41
|
|
|
42
|
+
A `running` attempt has no result of its own. When its step is cache-eligible
|
|
43
|
+
(sealed, with a hard boundary), a verified row another run recorded under the
|
|
44
|
+
same key answers it, and the row is closed as `succeeded` with that result. A
|
|
45
|
+
recorded successful effect crossing still wins over the cache, because it
|
|
46
|
+
carries this run's own result.
|
|
47
|
+
|
|
42
48
|
## Which columns are redacted
|
|
43
49
|
|
|
44
50
|
Attempt and run rows share a database with the journal, so failure text meets
|
|
@@ -62,6 +68,13 @@ A permit covers one run and step key through admission, execution, and
|
|
|
62
68
|
settlement. Concurrent same-key dispatches wait, then replay the settled
|
|
63
69
|
attempt instead of executing another body. Interruption releases the permit.
|
|
64
70
|
|
|
71
|
+
A cache-eligible dispatch also holds a permit for its cache address, which runs
|
|
72
|
+
share. A second run that asks one engine for a step that engine is already
|
|
73
|
+
computing waits, then takes the first run's cache row instead of running the
|
|
74
|
+
body beside it. Engines in other processes are not excluded: two processes can
|
|
75
|
+
still execute one key at once, and the second recording resolves as described
|
|
76
|
+
in [Cache admission](./cache-admission.md).
|
|
77
|
+
|
|
65
78
|
Holding the permit and the run fence permits recovery of a stranded `running`
|
|
66
79
|
attempt. A recorded successful effect crossing supplies the result directly;
|
|
67
80
|
an unresolved irreversible crossing requires an idempotency key before retry.
|
|
@@ -110,10 +110,16 @@ file (`store.sqlite3`), an `objects` directory, and a `restored.json` marker, an
|
|
|
110
110
|
is a fresh store directory to compose `NodeDatabase.layer` and
|
|
111
111
|
`ArtifactStore.layerFileSystem` over.
|
|
112
112
|
|
|
113
|
+
A failed restore removes what it wrote, so nothing store-shaped is left in the
|
|
114
|
+
target and you can retry into the same directory. A target that was not empty
|
|
115
|
+
is refused with `not_empty` and left untouched.
|
|
116
|
+
|
|
113
117
|
`restore` alone leaves the pre-backup owner fences in place. Run
|
|
114
118
|
`DisasterRecovery.fence(manifest)` against the restored file before any engine
|
|
115
119
|
adopts it, or use `restoreAndFence`, which does both and returns a
|
|
116
|
-
`FencedRestoredStore`.
|
|
120
|
+
`FencedRestoredStore`. When fencing refuses, `restoreAndFence` also removes the
|
|
121
|
+
restored files, so no engine can adopt a store that still carries pre-backup
|
|
122
|
+
owners.
|
|
117
123
|
|
|
118
124
|
## What fencing does
|
|
119
125
|
|
|
@@ -149,8 +155,8 @@ its own fresh owner.
|
|
|
149
155
|
| `not_empty` | A target or backup directory exists and is not empty. |
|
|
150
156
|
| `invalid_manifest` | The manifest is missing or does not decode. |
|
|
151
157
|
| `missing_file` | A file the manifest lists is absent. |
|
|
152
|
-
| `digest_mismatch` |
|
|
153
|
-
| `artifact_corruption` | A blob does not hash to its address.
|
|
158
|
+
| `digest_mismatch` | A backup file does not hash to its manifest digest. |
|
|
159
|
+
| `artifact_corruption` | A live blob does not hash to its address during a backup. |
|
|
154
160
|
| `snapshot_incomplete` | The snapshot references artifacts the capture could not take. |
|
|
155
161
|
| `schema_mismatch` | A recorded migration is missing or has a different name. |
|
|
156
162
|
| `io` | A host read or write refused, including an oversized file. |
|
|
@@ -93,6 +93,22 @@ at the staleness cutoff. A deferral is forgotten as soon as the row leaves the
|
|
|
93
93
|
stale window, so a fresh stall under a new lease is probed on the first tick
|
|
94
94
|
that sees it.
|
|
95
95
|
|
|
96
|
+
## What a reclaimed run does with its unfinished step
|
|
97
|
+
|
|
98
|
+
The step that was running when the owner died left an attempt row in state
|
|
99
|
+
`running`. The re-drive reaches that attempt again, and what it does depends on
|
|
100
|
+
the step:
|
|
101
|
+
|
|
102
|
+
- A cache-eligible step (sealed, with a hard boundary) asks the step cache
|
|
103
|
+
first. When another run already recorded the same key, for example the run
|
|
104
|
+
you started to replace the killed one, the reclaimed attempt takes that
|
|
105
|
+
result and the body does not run again. When that run is still computing it
|
|
106
|
+
in the same process, the reclaimed attempt waits for it.
|
|
107
|
+
- Any other step re-executes under its original attempt number. An
|
|
108
|
+
irreversible step whose effect was dispatched but never recorded as settled
|
|
109
|
+
needs an idempotency key to re-execute; without one the run fails with
|
|
110
|
+
`IrreversibleRetryRequiresIdempotencyKey`.
|
|
111
|
+
|
|
96
112
|
## Check a run's own view
|
|
97
113
|
|
|
98
114
|
`DurableEngineState` answers directly, which is useful in a diagnostic tool:
|
package/docs/installation.md
CHANGED
|
@@ -7,7 +7,11 @@ sidebar:
|
|
|
7
7
|
|
|
8
8
|
## Install the package
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
```bash
|
|
11
|
+
npm install @smthrs/engine-store@next effect
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `next` tag carries the 1.0 release candidates.
|
|
11
15
|
|
|
12
16
|
The package requires Node.js 26.4.0 or later and ships as both ESM and
|
|
13
17
|
CommonJS with TypeScript declarations. Its runtime dependencies, including
|
package/docs/quickstart.md
CHANGED
|
@@ -13,7 +13,11 @@ the whole point of the package.
|
|
|
13
13
|
## Prerequisites
|
|
14
14
|
|
|
15
15
|
- Node.js 26.4.0 or later.
|
|
16
|
-
- A
|
|
16
|
+
- A project with the packages installed:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @smthrs/engine-store@next @smthrs/artifacts@next @smthrs/database@next @smthrs/flow@next @smthrs/journal@next @smthrs/kernel@next @smthrs/platform-node@next @smthrs/run-store@next @smthrs/step-cache@next @effect/platform-node effect
|
|
20
|
+
```
|
|
17
21
|
|
|
18
22
|
## Declare a sealed action and a flow
|
|
19
23
|
|
|
@@ -193,6 +197,10 @@ console.log(await Effect.runPromise(main("./.quickstart/.flows/engine.db", "./.q
|
|
|
193
197
|
{ first: '42', second: '42', executions: 1 }
|
|
194
198
|
```
|
|
195
199
|
|
|
200
|
+
The assembled program is
|
|
201
|
+
[`examples/quickstart.ts`](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/engine-store/examples/quickstart.ts);
|
|
202
|
+
the package's test suite runs it on every change.
|
|
203
|
+
|
|
196
204
|
## What just happened
|
|
197
205
|
|
|
198
206
|
The first run claimed `analyse-1`, opened an attempt row for the sealed step,
|
package/docs/troubleshooting.md
CHANGED
|
@@ -304,6 +304,9 @@ migrations omit or rename a historical entry recorded in the manifest. Forward
|
|
|
304
304
|
additions within installed lower blocks are compatible; check historical IDs
|
|
305
305
|
and names before fencing again.
|
|
306
306
|
|
|
307
|
+
A failed backup, `restore`, or `restoreAndFence` removes the files it wrote, so
|
|
308
|
+
fix the cause and retry into the same directory.
|
|
309
|
+
|
|
307
310
|
## The composition does not compile
|
|
308
311
|
|
|
309
312
|
**What happened.** A required service is missing. A composition failure is an
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smthrs/engine-store",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.8",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"smthrs": {
|
|
7
7
|
"group": "engine"
|
|
@@ -386,29 +386,29 @@
|
|
|
386
386
|
"tag": "next"
|
|
387
387
|
},
|
|
388
388
|
"dependencies": {
|
|
389
|
-
"@smthrs/artifacts": "1.0.0-rc.
|
|
390
|
-
"@smthrs/canonical": "1.0.0-rc.
|
|
391
|
-
"@smthrs/crypto": "1.0.0-rc.
|
|
392
|
-
"@smthrs/database": "1.0.0-rc.
|
|
393
|
-
"@smthrs/engine": "1.0.0-rc.
|
|
394
|
-
"@smthrs/flow": "1.0.0-rc.
|
|
395
|
-
"@smthrs/journal": "1.0.0-rc.
|
|
396
|
-
"@smthrs/kernel": "1.0.0-rc.
|
|
397
|
-
"@smthrs/keys": "1.0.0-rc.
|
|
398
|
-
"@smthrs/plan": "1.0.0-rc.
|
|
399
|
-
"@smthrs/plan-store": "1.0.0-rc.
|
|
400
|
-
"@smthrs/run-store": "1.0.0-rc.
|
|
401
|
-
"@smthrs/step-cache": "1.0.0-rc.
|
|
402
|
-
"@smthrs/capability": "1.0.0-rc.
|
|
389
|
+
"@smthrs/artifacts": "1.0.0-rc.8",
|
|
390
|
+
"@smthrs/canonical": "1.0.0-rc.8",
|
|
391
|
+
"@smthrs/crypto": "1.0.0-rc.8",
|
|
392
|
+
"@smthrs/database": "1.0.0-rc.8",
|
|
393
|
+
"@smthrs/engine": "1.0.0-rc.8",
|
|
394
|
+
"@smthrs/flow": "1.0.0-rc.8",
|
|
395
|
+
"@smthrs/journal": "1.0.0-rc.8",
|
|
396
|
+
"@smthrs/kernel": "1.0.0-rc.8",
|
|
397
|
+
"@smthrs/keys": "1.0.0-rc.8",
|
|
398
|
+
"@smthrs/plan": "1.0.0-rc.8",
|
|
399
|
+
"@smthrs/plan-store": "1.0.0-rc.8",
|
|
400
|
+
"@smthrs/run-store": "1.0.0-rc.8",
|
|
401
|
+
"@smthrs/step-cache": "1.0.0-rc.8",
|
|
402
|
+
"@smthrs/capability": "1.0.0-rc.8"
|
|
403
403
|
},
|
|
404
404
|
"devDependencies": {
|
|
405
405
|
"@effect/language-service": "0.87.2",
|
|
406
406
|
"@effect/platform-node": "4.0.4",
|
|
407
407
|
"@effect/vitest": "4.0.4",
|
|
408
408
|
"@eslint/js": "9.39.5",
|
|
409
|
-
"@smthrs/core": "1.0.0-rc.
|
|
410
|
-
"@smthrs/patterns": "1.0.0-rc.
|
|
411
|
-
"@smthrs/platform-node": "1.0.0-rc.
|
|
409
|
+
"@smthrs/core": "1.0.0-rc.8",
|
|
410
|
+
"@smthrs/patterns": "1.0.0-rc.8",
|
|
411
|
+
"@smthrs/platform-node": "1.0.0-rc.8",
|
|
412
412
|
"@types/node": "26.4.1",
|
|
413
413
|
"@vitest/coverage-v8": "5.0.0",
|
|
414
414
|
"dprint": "0.57.1",
|
package/src/ArtifactSync.ts
CHANGED
|
@@ -241,6 +241,13 @@ export const make = (options: {
|
|
|
241
241
|
// them written into this workspace.
|
|
242
242
|
const written = yield* remote.get(digest).pipe(
|
|
243
243
|
Effect.flatMap((bytes) => local.put(bytes)),
|
|
244
|
+
// A custom tier may skip that verification; the address `put`
|
|
245
|
+
// returned is the proof the requested blob is now local.
|
|
246
|
+
Effect.flatMap((address) =>
|
|
247
|
+
address === digest ?
|
|
248
|
+
Effect.void :
|
|
249
|
+
Effect.fail({ message: `the shared tier served the bytes of ${address}` })
|
|
250
|
+
),
|
|
244
251
|
Effect.tapError(fallBack("the artifact could not be fetched", [digest])),
|
|
245
252
|
Effect.option
|
|
246
253
|
)
|
package/src/DisasterRecovery.ts
CHANGED
|
@@ -407,6 +407,23 @@ const ensureEmptyDirectory = (
|
|
|
407
407
|
}
|
|
408
408
|
})
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Removes every entry of a directory this module filled after admitting it
|
|
412
|
+
* empty, leaving the directory reusable for a retry. Cleanup is best effort;
|
|
413
|
+
* the caller reports the original failure.
|
|
414
|
+
*/
|
|
415
|
+
const discardEntries = (fs: FileSystem.FileSystem, directory: string): Effect.Effect<void> =>
|
|
416
|
+
fs.readDirectory(directory).pipe(
|
|
417
|
+
Effect.flatMap((entries) =>
|
|
418
|
+
Effect.forEach(
|
|
419
|
+
entries,
|
|
420
|
+
(entry) => fs.remove(`${directory}/${entry}`, { recursive: true, force: true }),
|
|
421
|
+
{ discard: true }
|
|
422
|
+
)
|
|
423
|
+
),
|
|
424
|
+
Effect.ignore
|
|
425
|
+
)
|
|
426
|
+
|
|
410
427
|
/**
|
|
411
428
|
* A relative blob path in the store's fanout layout: a two-hex directory
|
|
412
429
|
* holding a 64-hex digest that begins with it. Anything else under the
|
|
@@ -660,18 +677,8 @@ export const backup = <R = never, E = never>(
|
|
|
660
677
|
// The directory was empty when the capture began, so everything in it is
|
|
661
678
|
// this capture's: the snapshot, any SQLite sidecar the inspection
|
|
662
679
|
// connection left, and the copied blobs. A failed capture removes them and
|
|
663
|
-
// leaves the directory reusable for a retry.
|
|
664
|
-
|
|
665
|
-
const discardPartial = fs.readDirectory(options.directory).pipe(
|
|
666
|
-
Effect.flatMap((entries) =>
|
|
667
|
-
Effect.forEach(
|
|
668
|
-
entries,
|
|
669
|
-
(entry) => fs.remove(`${options.directory}/${entry}`, { recursive: true, force: true }),
|
|
670
|
-
{ discard: true }
|
|
671
|
-
)
|
|
672
|
-
),
|
|
673
|
-
Effect.ignore
|
|
674
|
-
)
|
|
680
|
+
// leaves the directory reusable for a retry.
|
|
681
|
+
const discardPartial = discardEntries(fs, options.directory)
|
|
675
682
|
|
|
676
683
|
return yield* (options.objectsDirectory === undefined
|
|
677
684
|
? capture
|
|
@@ -719,6 +726,57 @@ export const verify = Effect.fn("DisasterRecovery.verify")(function*(
|
|
|
719
726
|
return manifest
|
|
720
727
|
})
|
|
721
728
|
|
|
729
|
+
/** Copies a verified backup into an admitted empty target directory. */
|
|
730
|
+
const writeRestoredStore = (
|
|
731
|
+
fs: FileSystem.FileSystem,
|
|
732
|
+
options: RestoreOptions,
|
|
733
|
+
manifest: BackupManifest,
|
|
734
|
+
maxFileSizeBytes: number
|
|
735
|
+
): Effect.Effect<RestoredStore, DisasterRecoveryError, Crypto.Crypto> =>
|
|
736
|
+
Effect.gen(function*() {
|
|
737
|
+
const databaseBytes = yield* checkedFile(
|
|
738
|
+
fs,
|
|
739
|
+
"restore",
|
|
740
|
+
`${options.backupDirectory}/${manifest.database.file}`,
|
|
741
|
+
manifest.database.sha256,
|
|
742
|
+
maxFileSizeBytes
|
|
743
|
+
)
|
|
744
|
+
const databaseFile = `${options.targetDirectory}/${databaseFileName}`
|
|
745
|
+
yield* fs.writeFile(databaseFile, databaseBytes).pipe(
|
|
746
|
+
Effect.mapError(ioFailure("restore", `writing ${databaseFile}`))
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
const objectsDirectory = `${options.targetDirectory}/${objectsDirectoryName}`
|
|
750
|
+
for (const entry of manifest.artifacts) {
|
|
751
|
+
const bytes = yield* checkedFile(
|
|
752
|
+
fs,
|
|
753
|
+
"restore",
|
|
754
|
+
blobPath(`${options.backupDirectory}/${objectsDirectoryName}`, entry.digest),
|
|
755
|
+
entry.digest,
|
|
756
|
+
maxFileSizeBytes
|
|
757
|
+
)
|
|
758
|
+
yield* fs.makeDirectory(`${objectsDirectory}/${entry.digest.slice(0, 2)}`, { recursive: true }).pipe(
|
|
759
|
+
Effect.mapError(ioFailure("restore", `creating the objects directory for ${entry.digest}`))
|
|
760
|
+
)
|
|
761
|
+
yield* fs.writeFile(blobPath(objectsDirectory, entry.digest), bytes).pipe(
|
|
762
|
+
Effect.mapError(ioFailure("restore", `writing the blob for ${entry.digest}`))
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const restoredAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
|
|
767
|
+
const marker = {
|
|
768
|
+
formatVersion: 1,
|
|
769
|
+
restoredAtMs,
|
|
770
|
+
backupCreatedAtMs: manifest.createdAtMs,
|
|
771
|
+
databaseSha256: manifest.database.sha256
|
|
772
|
+
}
|
|
773
|
+
yield* fs.writeFileString(
|
|
774
|
+
`${options.targetDirectory}/${restoredMarkerFileName}`,
|
|
775
|
+
`${JSON.stringify(marker, null, 2)}\n`
|
|
776
|
+
).pipe(Effect.mapError(ioFailure("restore", `writing ${restoredMarkerFileName}`)))
|
|
777
|
+
return { databaseFile, objectsDirectory, manifest } satisfies RestoredStore
|
|
778
|
+
})
|
|
779
|
+
|
|
722
780
|
/**
|
|
723
781
|
* Restores a verified backup into an empty target directory.
|
|
724
782
|
*
|
|
@@ -728,7 +786,8 @@ export const verify = Effect.fn("DisasterRecovery.verify")(function*(
|
|
|
728
786
|
* is a fresh store directory to compose `NodeDatabase.layer` and
|
|
729
787
|
* `ArtifactStore.layerFileSystem` over. The database is restored with the
|
|
730
788
|
* pre-backup owner fences still in place — run {@link fence} against it
|
|
731
|
-
* before any engine adopts it.
|
|
789
|
+
* before any engine adopts it. A failed copy removes everything it wrote, so
|
|
790
|
+
* the target is left empty and reusable for a retry.
|
|
732
791
|
*
|
|
733
792
|
* @since 0.1.0
|
|
734
793
|
* @category operations
|
|
@@ -741,47 +800,13 @@ export const restore = Effect.fn("DisasterRecovery.restore")(function*(options:
|
|
|
741
800
|
const manifest = yield* readManifest(fs, "restore", options.backupDirectory, maxFileSizeBytes)
|
|
742
801
|
yield* ensureEmptyDirectory(fs, "restore", options.targetDirectory)
|
|
743
802
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
manifest
|
|
749
|
-
|
|
750
|
-
)
|
|
751
|
-
const databaseFile = `${options.targetDirectory}/${databaseFileName}`
|
|
752
|
-
yield* fs.writeFile(databaseFile, databaseBytes).pipe(
|
|
753
|
-
Effect.mapError(ioFailure("restore", `writing ${databaseFile}`))
|
|
803
|
+
// The target was empty when the copy began, so everything in it is this
|
|
804
|
+
// restore's. A refused blob or a failed write removes the partial store, so
|
|
805
|
+
// nothing store-shaped is left to compose over and a retry can reuse it.
|
|
806
|
+
return yield* Effect.onError(
|
|
807
|
+
writeRestoredStore(fs, options, manifest, maxFileSizeBytes),
|
|
808
|
+
() => discardEntries(fs, options.targetDirectory)
|
|
754
809
|
)
|
|
755
|
-
|
|
756
|
-
const objectsDirectory = `${options.targetDirectory}/${objectsDirectoryName}`
|
|
757
|
-
for (const entry of manifest.artifacts) {
|
|
758
|
-
const bytes = yield* checkedFile(
|
|
759
|
-
fs,
|
|
760
|
-
"restore",
|
|
761
|
-
blobPath(`${options.backupDirectory}/${objectsDirectoryName}`, entry.digest),
|
|
762
|
-
entry.digest,
|
|
763
|
-
maxFileSizeBytes
|
|
764
|
-
)
|
|
765
|
-
yield* fs.makeDirectory(`${objectsDirectory}/${entry.digest.slice(0, 2)}`, { recursive: true }).pipe(
|
|
766
|
-
Effect.mapError(ioFailure("restore", `creating the objects directory for ${entry.digest}`))
|
|
767
|
-
)
|
|
768
|
-
yield* fs.writeFile(blobPath(objectsDirectory, entry.digest), bytes).pipe(
|
|
769
|
-
Effect.mapError(ioFailure("restore", `writing the blob for ${entry.digest}`))
|
|
770
|
-
)
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const restoredAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
|
|
774
|
-
const marker = {
|
|
775
|
-
formatVersion: 1,
|
|
776
|
-
restoredAtMs,
|
|
777
|
-
backupCreatedAtMs: manifest.createdAtMs,
|
|
778
|
-
databaseSha256: manifest.database.sha256
|
|
779
|
-
}
|
|
780
|
-
yield* fs.writeFileString(
|
|
781
|
-
`${options.targetDirectory}/${restoredMarkerFileName}`,
|
|
782
|
-
`${JSON.stringify(marker, null, 2)}\n`
|
|
783
|
-
).pipe(Effect.mapError(ioFailure("restore", `writing ${restoredMarkerFileName}`)))
|
|
784
|
-
return { databaseFile, objectsDirectory, manifest } satisfies RestoredStore
|
|
785
810
|
})
|
|
786
811
|
|
|
787
812
|
/**
|
|
@@ -868,10 +893,12 @@ export const fence = Effect.fn("DisasterRecovery.fence")(function*(manifest: Bac
|
|
|
868
893
|
/**
|
|
869
894
|
* Restores a backup and immediately fences the restored database.
|
|
870
895
|
*
|
|
871
|
-
* This is the one-shot operator API for disaster recovery.
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
896
|
+
* This is the one-shot operator API for disaster recovery. When fencing
|
|
897
|
+
* refuses, the restored files are removed, so no engine can adopt a store that
|
|
898
|
+
* still carries pre-backup owners. The database driver itself still arrives
|
|
899
|
+
* through a caller-supplied Layer so this module remains host-neutral and
|
|
900
|
+
* browser-bundleable; the bundled Node script passes `NodeDatabase.layer` for
|
|
901
|
+
* the restored file.
|
|
875
902
|
*
|
|
876
903
|
* @since 0.1.0
|
|
877
904
|
* @category operations
|
|
@@ -880,9 +907,14 @@ export const restoreAndFence = <R = never, E = never>(
|
|
|
880
907
|
options: RestoreAndFenceOptions<R, E>
|
|
881
908
|
): Effect.Effect<FencedRestoredStore, DisasterRecoveryError | E, FileSystem.FileSystem | Crypto.Crypto | R> =>
|
|
882
909
|
Effect.gen(function*() {
|
|
910
|
+
const fs = yield* FileSystem.FileSystem
|
|
883
911
|
const restored = yield* restore(options)
|
|
912
|
+
// A refused fence leaves a database that still carries pre-backup owner
|
|
913
|
+
// fences beside a `restored.json` marker. Remove the whole restore so no
|
|
914
|
+
// engine can adopt it and the operator can retry into the same target.
|
|
884
915
|
const summary = yield* fence(restored.manifest).pipe(
|
|
885
|
-
Effect.provide(options.databaseLayer(restored.databaseFile))
|
|
916
|
+
Effect.provide(options.databaseLayer(restored.databaseFile)),
|
|
917
|
+
Effect.onError(() => discardEntries(fs, options.targetDirectory))
|
|
886
918
|
)
|
|
887
919
|
return { ...restored, fence: summary } satisfies FencedRestoredStore
|
|
888
920
|
})
|
package/src/StepBoundary.ts
CHANGED
|
@@ -496,12 +496,14 @@ export interface FileSystemOptions {
|
|
|
496
496
|
*/
|
|
497
497
|
readonly maxTotalInlineBytes?: number | undefined
|
|
498
498
|
/**
|
|
499
|
-
* The workspace root
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
499
|
+
* The workspace root every declared path resolves against: `prepare` and
|
|
500
|
+
* `settle` measure under it, and cache replay confines its writes, removals,
|
|
501
|
+
* and prune walk to it. A filesystem already confined to a root (the
|
|
502
|
+
* kernel-guarded layer's service, or a `confined` view) keeps its own root
|
|
503
|
+
* and ignores this. Otherwise it defaults to the process working directory,
|
|
504
|
+
* the root a path-based host resolves a relative path against. {@link layer}
|
|
505
|
+
* passes the kernel `Workspace` root when one is in context, which is the
|
|
506
|
+
* root a step's own FileSystem resolves against (issue #3911).
|
|
505
507
|
*/
|
|
506
508
|
readonly root?: string | undefined
|
|
507
509
|
}
|
|
@@ -573,7 +575,16 @@ export const makeFileSystem = (
|
|
|
573
575
|
const digest = yield* Schema.decodeUnknownEffect(Sha256)(bytes).pipe(Effect.orDie)
|
|
574
576
|
return { digest, bytes } satisfies MeasuredDigest
|
|
575
577
|
})
|
|
576
|
-
|
|
578
|
+
// Declared paths are workspace-relative. A confined filesystem resolves them
|
|
579
|
+
// against its own root, but a path-based host resolves them against the
|
|
580
|
+
// process working directory, where a same-named file would answer the dirty
|
|
581
|
+
// check and let a stale declaration serve a cache hit (issue #3911). Such a
|
|
582
|
+
// host is handed each path resolved against the configured root; results
|
|
583
|
+
// stay keyed by the declared path.
|
|
584
|
+
const readRoot = KernelFileSystem.confinedRoot(fs) === undefined ? options.root : undefined
|
|
585
|
+
const hostPath = (path: string): string => readRoot === undefined ? path : platformPath.resolve(readRoot, path)
|
|
586
|
+
const enumeration: FileEnumeration.EnumerationOptions = readRoot === undefined ? {} : { resolve: hostPath }
|
|
587
|
+
const readDigest = (path: string) => readDigestFrom(fs, hostPath(path))
|
|
577
588
|
const batch = KernelFileSystem.batch(fs)
|
|
578
589
|
const measurements = Effect.fn("StepBoundary.measurements")(
|
|
579
590
|
function*(paths: ReadonlyArray<string>, content: boolean) {
|
|
@@ -594,7 +605,7 @@ export const makeFileSystem = (
|
|
|
594
605
|
const response = yield* batch.execute(
|
|
595
606
|
paths.slice(offset, offset + batch.maxSize).map((path) => ({
|
|
596
607
|
operation: "digest",
|
|
597
|
-
path,
|
|
608
|
+
path: hostPath(path),
|
|
598
609
|
content
|
|
599
610
|
}))
|
|
600
611
|
).pipe(Effect.mapError(hostFailure))
|
|
@@ -636,10 +647,14 @@ export const makeFileSystem = (
|
|
|
636
647
|
// workspace-relative expansion and let a tree replay delete dotfiles the
|
|
637
648
|
// producer wrote.
|
|
638
649
|
const expandGlob = Effect.fn("StepBoundary.expandGlob")(function*(glob: FileSet.Glob) {
|
|
639
|
-
return yield* FileEnumeration.expandGlob(fs, glob).pipe(Effect.mapError(hostFailure))
|
|
650
|
+
return yield* FileEnumeration.expandGlob(fs, glob, enumeration).pipe(Effect.mapError(hostFailure))
|
|
640
651
|
})
|
|
641
|
-
const treeEntries = Effect.fn("StepBoundary.treeEntries")(function*(
|
|
642
|
-
|
|
652
|
+
const treeEntries = Effect.fn("StepBoundary.treeEntries")(function*(
|
|
653
|
+
host: FileSystem.FileSystem,
|
|
654
|
+
path: string,
|
|
655
|
+
resolution: FileEnumeration.EnumerationOptions
|
|
656
|
+
) {
|
|
657
|
+
return yield* FileEnumeration.entriesUnder(host, path, resolution).pipe(Effect.mapError(hostFailure))
|
|
643
658
|
})
|
|
644
659
|
// Replay writes and deletes what evidence names, so the lexical check alone
|
|
645
660
|
// is not a boundary: a symlink planted on a lexically valid path would carry
|
|
@@ -647,7 +662,7 @@ export const makeFileSystem = (
|
|
|
647
662
|
// its prune walk go through a view pinned to the root, where each call is
|
|
648
663
|
// one descriptor-relative, no-follow request and a symlink anywhere on the
|
|
649
664
|
// path is refused rather than followed. `prepare` and `settle` only read,
|
|
650
|
-
// and keep the caller's filesystem
|
|
665
|
+
// and keep the caller's filesystem, rooted through `hostPath`.
|
|
651
666
|
const replayRoot = KernelFileSystem.confinedRoot(fs) ?? options.root ?? "."
|
|
652
667
|
const replayHost: Effect.Effect<FileSystem.FileSystem, UnsupportedBoundary> = KernelFileSystem.isConfinable(fs)
|
|
653
668
|
? KernelFileSystem.confined(fs, replayRoot).pipe(
|
|
@@ -849,7 +864,7 @@ export const makeFileSystem = (
|
|
|
849
864
|
if (typeof entry === "string") outputPaths.push(entry)
|
|
850
865
|
else if (entry._tag === "Glob") outputPaths.push(...yield* expandGlob(entry))
|
|
851
866
|
else {
|
|
852
|
-
const files = (yield* treeEntries(fs, entry.path)).files
|
|
867
|
+
const files = (yield* treeEntries(fs, entry.path, enumeration)).files
|
|
853
868
|
outputPaths.push(...files)
|
|
854
869
|
treeMembers.push({ path: entry.path, files })
|
|
855
870
|
}
|
|
@@ -1007,7 +1022,7 @@ export const makeFileSystem = (
|
|
|
1007
1022
|
.map((output) => output.path)
|
|
1008
1023
|
.filter((path) => path.startsWith(prefix))
|
|
1009
1024
|
)
|
|
1010
|
-
const entries = yield* treeEntries(host, tree.path)
|
|
1025
|
+
const entries = yield* treeEntries(host, tree.path, {})
|
|
1011
1026
|
for (const path of entries.files) {
|
|
1012
1027
|
if (!recorded.has(path)) yield* host.remove(path).pipe(Effect.mapError(hostFailure))
|
|
1013
1028
|
}
|
package/src/index.ts
CHANGED
|
@@ -67,7 +67,21 @@ export * as RunChangeFeed from "./RunChangeFeed.ts"
|
|
|
67
67
|
* @category services
|
|
68
68
|
*/
|
|
69
69
|
export * as PlanInputStore from "./PlanInputStore.ts"
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Owner-fenced plan merge decisions, recorded before a merge is appended.
|
|
73
|
+
*
|
|
74
|
+
* @since 1.0.0
|
|
75
|
+
* @category services
|
|
76
|
+
*/
|
|
70
77
|
export * as PlanMergeStore from "./PlanMergeStore.ts"
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drives a persisted plan: the node scheduler.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.1.0
|
|
83
|
+
* @category services
|
|
84
|
+
*/
|
|
71
85
|
export * as PlanScheduler from "./PlanScheduler.ts"
|
|
72
86
|
|
|
73
87
|
/**
|