@variance-authority/scenario 0.1.0
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 +14 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/archive.d.ts +66 -0
- package/dist/archive.js +222 -0
- package/dist/archive.js.map +1 -0
- package/dist/assessment.d.ts +4 -0
- package/dist/assessment.js +157 -0
- package/dist/assessment.js.map +1 -0
- package/dist/contract.d.ts +190 -0
- package/dist/contract.js +80 -0
- package/dist/contract.js.map +1 -0
- package/dist/execution.d.ts +17 -0
- package/dist/execution.js +181 -0
- package/dist/execution.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/machine.d.ts +33 -0
- package/dist/machine.js +84 -0
- package/dist/machine.js.map +1 -0
- package/mark.svg +30 -0
- package/package.json +48 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @variance-authority/scenario
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 92f62d4: Name the `startScenario` option a host left out. `precondition` and `profile`
|
|
8
|
+
are required and were read before they were checked, so omitting `precondition`
|
|
9
|
+
answered `Cannot read properties of undefined (reading 'id')` from inside the
|
|
10
|
+
module while `id` — required in the same way, on the same options object —
|
|
11
|
+
already answered with a sentence. All three now do.
|
|
12
|
+
- Updated dependencies [e8fee66]
|
|
13
|
+
- Updated dependencies [5c34e6d]
|
|
14
|
+
- @variance-authority/core@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Machine Garden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
|
|
2
|
+
|
|
3
|
+
# @variance-authority/scenario
|
|
4
|
+
|
|
5
|
+
> Record runtime scenarios as AAA state machines and assess variance across witnessed transitions.
|
|
6
|
+
|
|
7
|
+
Use this package when a host can arrange a named UI state, perform meaningful
|
|
8
|
+
acts, and attempt a semantic snapshot after each one. A scenario is AAA
|
|
9
|
+
(Arrange-Act-Assert) as a state machine: Arrange is the initial state
|
|
10
|
+
observation, each Act labels a transition, and assessment compares the
|
|
11
|
+
variance across those transitions.
|
|
12
|
+
|
|
13
|
+
The host remains responsible for producing `page-loading`, `page-error`,
|
|
14
|
+
`page-one-article`, or any other precondition. If the host already resolved
|
|
15
|
+
this precondition's parent subject, pass that parent's id and whether it was
|
|
16
|
+
declared or named as `preconditionLink`; this package only records that
|
|
17
|
+
evidence and never tries to infer fixtures, mocks, cookies, routes, or flags.
|
|
18
|
+
|
|
19
|
+
The snapshots come from the host as well. A `SemanticSnapshot` is the normalized
|
|
20
|
+
capture of a rendered subject that `@variance-authority/core` produces from a
|
|
21
|
+
collector's raw capture; nothing here produces one. The optional archive writes
|
|
22
|
+
to a directory you give it.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install --save-dev @variance-authority/scenario
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Record and assess two paths
|
|
29
|
+
|
|
30
|
+
Each `SemanticSnapshot` below is a `declare const` stand-in: a real one comes
|
|
31
|
+
from a collector capturing a rendered subject and `@variance-authority/core`
|
|
32
|
+
normalizing it, not from this package.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import type { SemanticSnapshot } from '@variance-authority/core';
|
|
36
|
+
import {
|
|
37
|
+
assessScenarios,
|
|
38
|
+
defineScenario,
|
|
39
|
+
recordAct,
|
|
40
|
+
startScenario,
|
|
41
|
+
} from '@variance-authority/scenario';
|
|
42
|
+
|
|
43
|
+
declare const oneArticleBefore: SemanticSnapshot;
|
|
44
|
+
declare const oneArticleAfter: SemanticSnapshot;
|
|
45
|
+
declare const twoArticlesBefore: SemanticSnapshot;
|
|
46
|
+
declare const twoArticlesAfter: SemanticSnapshot;
|
|
47
|
+
|
|
48
|
+
const definition = defineScenario('delete-an-article', [{ key: 'delete-first' }]);
|
|
49
|
+
|
|
50
|
+
const oneArticle = recordAct(
|
|
51
|
+
startScenario(
|
|
52
|
+
definition,
|
|
53
|
+
{
|
|
54
|
+
id: 'run-1/one-article',
|
|
55
|
+
precondition: oneArticleBefore.subject,
|
|
56
|
+
profile: oneArticleBefore.profile.id,
|
|
57
|
+
},
|
|
58
|
+
oneArticleBefore,
|
|
59
|
+
),
|
|
60
|
+
'delete-first',
|
|
61
|
+
oneArticleAfter,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const twoArticles = recordAct(
|
|
65
|
+
startScenario(
|
|
66
|
+
definition,
|
|
67
|
+
{
|
|
68
|
+
id: 'run-1/two-articles',
|
|
69
|
+
precondition: twoArticlesBefore.subject,
|
|
70
|
+
profile: twoArticlesBefore.profile.id,
|
|
71
|
+
},
|
|
72
|
+
twoArticlesBefore,
|
|
73
|
+
),
|
|
74
|
+
'delete-first',
|
|
75
|
+
twoArticlesAfter,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const assessment = assessScenarios(oneArticle, twoArticles);
|
|
79
|
+
console.log(assessment.arrange, assessment.firstDivergence);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each `startScenario` or `recordAct` call above appends one frame to the
|
|
83
|
+
execution — Arrange's own outcome, or one Act's — pairing that frame's render
|
|
84
|
+
hash (the exact content digest of what rendered) with its semantic snapshot.
|
|
85
|
+
|
|
86
|
+
`arrange` compares the initial observations. Each transition then carries the
|
|
87
|
+
left effect, the right effect, and whether the two agree; an effect digest is
|
|
88
|
+
the digest of the classified semantic variance between an Act's before and
|
|
89
|
+
after snapshot, not the frame's own render hash. A shared token edit moves
|
|
90
|
+
every frame's render hash — its exact, absolute state — yet leaves the effect
|
|
91
|
+
digest stable, because the edit is already present in an Act's before and
|
|
92
|
+
after frame alike; changing the handler does move the digest at that Act,
|
|
93
|
+
because that is exactly the comparison the delta is taken from.
|
|
94
|
+
|
|
95
|
+
Acts align by `(key, occurrence)` along their common ordered prefix. An inserted,
|
|
96
|
+
missing, or repeated Act is reported in `unmatched` with its side; later ordinals are not
|
|
97
|
+
shifted into a plausible pair. A failed observation is created with
|
|
98
|
+
`unobserved(...)`, terminates the reachable prefix, and makes
|
|
99
|
+
`firstDivergence` unresolved rather than clean.
|
|
100
|
+
|
|
101
|
+
`startScenario` takes:
|
|
102
|
+
|
|
103
|
+
| option | required state | what it decides |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `id` | required, non-empty | the execution identity within its run |
|
|
106
|
+
| `precondition` | required | a `SubjectRef` — the host's stable subject id and kind — in the role of Arrange |
|
|
107
|
+
| `profile` | required | the observation profile every frame must match |
|
|
108
|
+
| `preconditionLink` | absent | the resolved parent and its declared or named evidence; omission means no link was supplied |
|
|
109
|
+
|
|
110
|
+
## Fold executions into the machine
|
|
111
|
+
|
|
112
|
+
`foldScenarios` groups frames by render hash and adds witnessed transitions under
|
|
113
|
+
their Act keys. Paths reaching one hash converge on one node. If one state and
|
|
114
|
+
Act reach two destination hashes, both transitions remain and the machine reports
|
|
115
|
+
the divergence. An unobserved Act appears under `unknown`; an edge no execution
|
|
116
|
+
witnessed does not exist in the value and is never called impossible.
|
|
117
|
+
|
|
118
|
+
This does not replay anything: folding only turns already-recorded frames into
|
|
119
|
+
a graph of witnessed states and edges.
|
|
120
|
+
|
|
121
|
+
## Retain semantic evidence explicitly
|
|
122
|
+
|
|
123
|
+
The root entrypoint is ephemeral and performs no I/O. Import
|
|
124
|
+
`createScenarioArchive` from `@variance-authority/scenario/archive` only when the
|
|
125
|
+
semantic evidence must survive the process.
|
|
126
|
+
|
|
127
|
+
The archive requires an address covering project, run, scenario, execution,
|
|
128
|
+
precondition, profile, and attempt — a caller-assigned label, such as a retry
|
|
129
|
+
count, that the archive never derives from the run itself. Its policy declares
|
|
130
|
+
expiry, access, deletion, and an admission function — a check that can refuse
|
|
131
|
+
to retain a given snapshot — for every semantic snapshot. Equal snapshots are
|
|
132
|
+
stored once by content digest. Expired or missing evidence reads as
|
|
133
|
+
`unobserved`; garbage collection removes expired manifests and semantic objects
|
|
134
|
+
no retained manifest references.
|
|
135
|
+
|
|
136
|
+
Only canonical `SemanticSnapshot` text and a versioned manifest enter the
|
|
137
|
+
archive — this does not store screenshots or make pass/fail decisions. Semantic
|
|
138
|
+
snapshots can still contain document text, accessible names, attributes, URLs,
|
|
139
|
+
and source evidence, so an admission policy that cannot retain those values
|
|
140
|
+
must refuse the snapshot instead of redacting it after hashing.
|
|
141
|
+
|
|
142
|
+
`createScenarioArchive` takes `root`, the writable archive directory, and an
|
|
143
|
+
optional `now` clock for deterministic expiry decisions. The system clock is the
|
|
144
|
+
default. Neither option enables archival by itself; only calling `put` writes.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type Digest, type SemanticSnapshot } from '@variance-authority/core';
|
|
2
|
+
import type { ScenarioDefinitionData, ScenarioExecutionData, ScenarioRun } from './contract.js';
|
|
3
|
+
export interface ScenarioArchiveAddress {
|
|
4
|
+
readonly project: string;
|
|
5
|
+
readonly run: string;
|
|
6
|
+
readonly scenario: string;
|
|
7
|
+
readonly execution: string;
|
|
8
|
+
readonly precondition: string;
|
|
9
|
+
readonly profile: string;
|
|
10
|
+
readonly attempt: string;
|
|
11
|
+
}
|
|
12
|
+
export type ScenarioArchiveAdmission = {
|
|
13
|
+
readonly kind: 'admitted';
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: 'refused';
|
|
16
|
+
readonly because: string;
|
|
17
|
+
};
|
|
18
|
+
export interface ScenarioArchivePolicy {
|
|
19
|
+
/** ISO-8601 instant after which the evidence is unavailable. */
|
|
20
|
+
readonly retainUntil: string;
|
|
21
|
+
/** Human-readable declaration of who may read the retained semantic text. */
|
|
22
|
+
readonly access: string;
|
|
23
|
+
/** Human-readable declaration of how retained evidence is deleted. */
|
|
24
|
+
readonly deletion: string;
|
|
25
|
+
readonly admit: (snapshot: SemanticSnapshot) => ScenarioArchiveAdmission;
|
|
26
|
+
}
|
|
27
|
+
/** Retention and identity envelope for one admitted, witnessed scenario execution. */
|
|
28
|
+
export interface ScenarioArchiveManifest {
|
|
29
|
+
readonly archiveVersion: 1;
|
|
30
|
+
readonly address: ScenarioArchiveAddress;
|
|
31
|
+
readonly retainUntil: string;
|
|
32
|
+
readonly access: string;
|
|
33
|
+
readonly deletion: string;
|
|
34
|
+
readonly definition: ScenarioDefinitionData;
|
|
35
|
+
readonly execution: ScenarioExecutionData;
|
|
36
|
+
readonly snapshots: readonly Digest[];
|
|
37
|
+
}
|
|
38
|
+
export type ArchivedScenario = {
|
|
39
|
+
readonly kind: 'observed';
|
|
40
|
+
readonly run: ScenarioRun;
|
|
41
|
+
readonly manifest: ScenarioArchiveManifest;
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: 'unobserved';
|
|
44
|
+
readonly because: string;
|
|
45
|
+
};
|
|
46
|
+
export interface ScenarioArchiveCollection {
|
|
47
|
+
readonly manifests: number;
|
|
48
|
+
readonly snapshots: number;
|
|
49
|
+
}
|
|
50
|
+
export interface ScenarioArchive {
|
|
51
|
+
put(address: ScenarioArchiveAddress, run: ScenarioRun, policy: ScenarioArchivePolicy): Promise<ScenarioArchiveManifest>;
|
|
52
|
+
read(address: ScenarioArchiveAddress): Promise<ArchivedScenario>;
|
|
53
|
+
collectExpired(): Promise<ScenarioArchiveCollection>;
|
|
54
|
+
}
|
|
55
|
+
export interface ScenarioArchiveOptions {
|
|
56
|
+
readonly root: string;
|
|
57
|
+
readonly now?: () => Date;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open an opt-in, text-only archive. Nothing calls this from the default execution path.
|
|
61
|
+
*
|
|
62
|
+
* The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no
|
|
63
|
+
* raster, document, event payload, baseline, or history-row write.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createScenarioArchive(options: ScenarioArchiveOptions): ScenarioArchive;
|
|
66
|
+
//# sourceMappingURL=archive.d.ts.map
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { canonicalize, digestValue, } from '@variance-authority/core';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { checkedDefinition, checkedExecution, checkedRun } from './contract.js';
|
|
6
|
+
import { assertScenarioRun, semanticSnapshotDigest } from './execution.js';
|
|
7
|
+
/**
|
|
8
|
+
* Open an opt-in, text-only archive. Nothing calls this from the default execution path.
|
|
9
|
+
*
|
|
10
|
+
* The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no
|
|
11
|
+
* raster, document, event payload, baseline, or history-row write.
|
|
12
|
+
*/
|
|
13
|
+
export function createScenarioArchive(options) {
|
|
14
|
+
const root = options.root;
|
|
15
|
+
const now = options.now ?? (() => new Date());
|
|
16
|
+
const objects = join(root, 'objects');
|
|
17
|
+
const manifests = join(root, 'manifests');
|
|
18
|
+
return {
|
|
19
|
+
async put(address, run, policy) {
|
|
20
|
+
assertScenarioRun(run);
|
|
21
|
+
validateAddress(address, run);
|
|
22
|
+
const expires = parseExpiry(policy.retainUntil);
|
|
23
|
+
if (expires <= now().getTime()) {
|
|
24
|
+
throw new Error('refusing scenario archive policy whose retention has already expired');
|
|
25
|
+
}
|
|
26
|
+
requireDeclaration(policy.access, 'access');
|
|
27
|
+
requireDeclaration(policy.deletion, 'deletion');
|
|
28
|
+
const digests = observedDigests(run.execution);
|
|
29
|
+
for (const digest of digests) {
|
|
30
|
+
const snapshot = run.snapshots.get(digest);
|
|
31
|
+
if (snapshot === undefined) {
|
|
32
|
+
throw new Error(`refusing scenario archive with unavailable snapshot ${digest}`);
|
|
33
|
+
}
|
|
34
|
+
const admission = policy.admit(snapshot);
|
|
35
|
+
if (admission.kind === 'refused') {
|
|
36
|
+
throw new Error(`refusing semantic snapshot ${digest}: ${admission.because}`);
|
|
37
|
+
}
|
|
38
|
+
if (semanticSnapshotDigest(snapshot) !== digest) {
|
|
39
|
+
throw new Error(`refusing semantic snapshot ${digest}: its content address does not match`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await mkdir(objects, { recursive: true });
|
|
43
|
+
await mkdir(manifests, { recursive: true });
|
|
44
|
+
for (const digest of digests) {
|
|
45
|
+
await writeObject(join(objects, `${fileDigest(digest)}.json`), run.snapshots.get(digest));
|
|
46
|
+
}
|
|
47
|
+
const manifest = {
|
|
48
|
+
archiveVersion: 1,
|
|
49
|
+
address,
|
|
50
|
+
retainUntil: policy.retainUntil,
|
|
51
|
+
access: policy.access,
|
|
52
|
+
deletion: policy.deletion,
|
|
53
|
+
definition: run.definition,
|
|
54
|
+
execution: run.execution,
|
|
55
|
+
snapshots: digests,
|
|
56
|
+
};
|
|
57
|
+
await writeAtomic(manifestPath(manifests, address), canonicalize(asCanonical(manifest)));
|
|
58
|
+
return manifest;
|
|
59
|
+
},
|
|
60
|
+
async read(address) {
|
|
61
|
+
const path = manifestPath(manifests, address);
|
|
62
|
+
const manifest = await readOptional(path);
|
|
63
|
+
if (manifest === undefined) {
|
|
64
|
+
return { kind: 'unobserved', because: 'no archived scenario exists at this address' };
|
|
65
|
+
}
|
|
66
|
+
if (parseExpiry(manifest.retainUntil) <= now().getTime()) {
|
|
67
|
+
return {
|
|
68
|
+
kind: 'unobserved',
|
|
69
|
+
because: `the archived scenario expired at ${manifest.retainUntil}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const snapshots = new Map();
|
|
73
|
+
for (const digest of manifest.snapshots) {
|
|
74
|
+
const snapshot = await readOptional(join(objects, `${fileDigest(digest)}.json`));
|
|
75
|
+
if (snapshot === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
kind: 'unobserved',
|
|
78
|
+
because: `archived semantic snapshot ${digest} is unavailable`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (semanticSnapshotDigest(snapshot) !== digest) {
|
|
82
|
+
throw new Error(`archived semantic snapshot ${digest} failed its content address`);
|
|
83
|
+
}
|
|
84
|
+
snapshots.set(digest, snapshot);
|
|
85
|
+
}
|
|
86
|
+
const run = checkedRun({
|
|
87
|
+
definition: checkedDefinition(manifest.definition),
|
|
88
|
+
execution: checkedExecution(manifest.execution),
|
|
89
|
+
snapshots,
|
|
90
|
+
});
|
|
91
|
+
assertScenarioRun(run);
|
|
92
|
+
return {
|
|
93
|
+
kind: 'observed',
|
|
94
|
+
manifest,
|
|
95
|
+
run,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
async collectExpired() {
|
|
99
|
+
const manifestFiles = await jsonFiles(manifests);
|
|
100
|
+
const retained = new Set();
|
|
101
|
+
let removedManifests = 0;
|
|
102
|
+
for (const file of manifestFiles) {
|
|
103
|
+
const manifest = await readRequired(join(manifests, file));
|
|
104
|
+
if (parseExpiry(manifest.retainUntil) <= now().getTime()) {
|
|
105
|
+
await rm(join(manifests, file));
|
|
106
|
+
removedManifests += 1;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
for (const digest of manifest.snapshots)
|
|
110
|
+
retained.add(digest);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let removedSnapshots = 0;
|
|
114
|
+
for (const file of await jsonFiles(objects)) {
|
|
115
|
+
const snapshot = await readRequired(join(objects, file));
|
|
116
|
+
const digest = semanticSnapshotDigest(snapshot);
|
|
117
|
+
if (!retained.has(digest)) {
|
|
118
|
+
await rm(join(objects, file));
|
|
119
|
+
removedSnapshots += 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { manifests: removedManifests, snapshots: removedSnapshots };
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function observedDigests(execution) {
|
|
127
|
+
return [...new Set(execution.frames.flatMap((frame) => frame.outcome.kind === 'observed' ? [frame.outcome.snapshot] : []))].sort(compare);
|
|
128
|
+
}
|
|
129
|
+
function validateAddress(address, run) {
|
|
130
|
+
const mismatches = [
|
|
131
|
+
address.scenario === run.definition.id ? undefined : 'scenario',
|
|
132
|
+
address.execution === run.execution.id ? undefined : 'execution',
|
|
133
|
+
address.precondition === run.execution.precondition.id ? undefined : 'precondition',
|
|
134
|
+
address.profile === run.execution.profile ? undefined : 'profile',
|
|
135
|
+
].filter((field) => field !== undefined);
|
|
136
|
+
if (mismatches.length > 0) {
|
|
137
|
+
throw new Error(`refusing scenario archive address with mismatched ${mismatches.join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
for (const [field, value] of Object.entries(address))
|
|
140
|
+
requireDeclaration(value, field);
|
|
141
|
+
}
|
|
142
|
+
function parseExpiry(value) {
|
|
143
|
+
const timestamp = Date.parse(value);
|
|
144
|
+
if (!Number.isFinite(timestamp))
|
|
145
|
+
throw new Error(`invalid scenario retention instant: ${value}`);
|
|
146
|
+
return timestamp;
|
|
147
|
+
}
|
|
148
|
+
function requireDeclaration(value, what) {
|
|
149
|
+
if (value.trim() === '')
|
|
150
|
+
throw new Error(`scenario archive ${what} must not be empty`);
|
|
151
|
+
}
|
|
152
|
+
function manifestPath(root, address) {
|
|
153
|
+
return join(root, `${fileDigest(digestValue(asCanonical(address)))}.json`);
|
|
154
|
+
}
|
|
155
|
+
function fileDigest(digest) {
|
|
156
|
+
return digest.replace(':', '-');
|
|
157
|
+
}
|
|
158
|
+
function asCanonical(value) {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
async function writeObject(path, snapshot) {
|
|
162
|
+
const content = canonicalize(asCanonical(snapshot));
|
|
163
|
+
const existing = await readText(path);
|
|
164
|
+
if (existing !== undefined) {
|
|
165
|
+
if (existing !== content)
|
|
166
|
+
throw new Error(`content-address collision at ${path}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await writeAtomic(path, content);
|
|
170
|
+
}
|
|
171
|
+
async function writeAtomic(path, content) {
|
|
172
|
+
await mkdir(dirname(path), { recursive: true });
|
|
173
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
|
+
await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
|
|
175
|
+
try {
|
|
176
|
+
await rename(temporary, path);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
await rm(temporary, { force: true });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function readOptional(path) {
|
|
184
|
+
const text = await readText(path);
|
|
185
|
+
return text === undefined ? undefined : JSON.parse(text);
|
|
186
|
+
}
|
|
187
|
+
async function readRequired(path) {
|
|
188
|
+
const found = await readOptional(path);
|
|
189
|
+
if (found === undefined)
|
|
190
|
+
throw new Error(`archive entry disappeared while reading ${path}`);
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
async function readText(path) {
|
|
194
|
+
try {
|
|
195
|
+
return await readFile(path, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (isMissing(error))
|
|
199
|
+
return undefined;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function jsonFiles(path) {
|
|
204
|
+
try {
|
|
205
|
+
return (await readdir(path)).filter((file) => file.endsWith('.json')).sort(compare);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (isMissing(error))
|
|
209
|
+
return [];
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function isMissing(error) {
|
|
214
|
+
return (typeof error === 'object' &&
|
|
215
|
+
error !== null &&
|
|
216
|
+
'code' in error &&
|
|
217
|
+
error.code === 'ENOENT');
|
|
218
|
+
}
|
|
219
|
+
function compare(left, right) {
|
|
220
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"archive.js","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n canonicalize,\n digestValue,\n type CanonicalValue,\n type Digest,\n type SemanticSnapshot,\n} from '@variance-authority/core';\nimport { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { randomUUID } from 'node:crypto';\nimport type {\n ScenarioDefinitionData,\n ScenarioExecutionData,\n ScenarioRun,\n} from './contract.js';\nimport { checkedDefinition, checkedExecution, checkedRun } from './contract.js';\nimport { assertScenarioRun, semanticSnapshotDigest } from './execution.js';\n\nexport interface ScenarioArchiveAddress {\n readonly project: string;\n readonly run: string;\n readonly scenario: string;\n readonly execution: string;\n readonly precondition: string;\n readonly profile: string;\n readonly attempt: string;\n}\n\nexport type ScenarioArchiveAdmission =\n | { readonly kind: 'admitted' }\n | { readonly kind: 'refused'; readonly because: string };\n\nexport interface ScenarioArchivePolicy {\n /** ISO-8601 instant after which the evidence is unavailable. */\n readonly retainUntil: string;\n /** Human-readable declaration of who may read the retained semantic text. */\n readonly access: string;\n /** Human-readable declaration of how retained evidence is deleted. */\n readonly deletion: string;\n readonly admit: (snapshot: SemanticSnapshot) => ScenarioArchiveAdmission;\n}\n\n/** Retention and identity envelope for one admitted, witnessed scenario execution. */\nexport interface ScenarioArchiveManifest {\n readonly archiveVersion: 1;\n readonly address: ScenarioArchiveAddress;\n readonly retainUntil: string;\n readonly access: string;\n readonly deletion: string;\n readonly definition: ScenarioDefinitionData;\n readonly execution: ScenarioExecutionData;\n readonly snapshots: readonly Digest[];\n}\n\nexport type ArchivedScenario =\n | { readonly kind: 'observed'; readonly run: ScenarioRun; readonly manifest: ScenarioArchiveManifest }\n | { readonly kind: 'unobserved'; readonly because: string };\n\nexport interface ScenarioArchiveCollection {\n readonly manifests: number;\n readonly snapshots: number;\n}\n\nexport interface ScenarioArchive {\n put(\n address: ScenarioArchiveAddress,\n run: ScenarioRun,\n policy: ScenarioArchivePolicy,\n ): Promise<ScenarioArchiveManifest>;\n read(address: ScenarioArchiveAddress): Promise<ArchivedScenario>;\n collectExpired(): Promise<ScenarioArchiveCollection>;\n}\n\nexport interface ScenarioArchiveOptions {\n readonly root: string;\n readonly now?: () => Date;\n}\n\n/**\n * Open an opt-in, text-only archive. Nothing calls this from the default execution path.\n *\n * The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no\n * raster, document, event payload, baseline, or history-row write.\n */\nexport function createScenarioArchive(options: ScenarioArchiveOptions): ScenarioArchive {\n const root = options.root;\n const now = options.now ?? (() => new Date());\n const objects = join(root, 'objects');\n const manifests = join(root, 'manifests');\n\n return {\n async put(address, run, policy) {\n assertScenarioRun(run);\n validateAddress(address, run);\n const expires = parseExpiry(policy.retainUntil);\n if (expires <= now().getTime()) {\n throw new Error('refusing scenario archive policy whose retention has already expired');\n }\n requireDeclaration(policy.access, 'access');\n requireDeclaration(policy.deletion, 'deletion');\n\n const digests = observedDigests(run.execution);\n for (const digest of digests) {\n const snapshot = run.snapshots.get(digest);\n if (snapshot === undefined) {\n throw new Error(`refusing scenario archive with unavailable snapshot ${digest}`);\n }\n const admission = policy.admit(snapshot);\n if (admission.kind === 'refused') {\n throw new Error(`refusing semantic snapshot ${digest}: ${admission.because}`);\n }\n if (semanticSnapshotDigest(snapshot) !== digest) {\n throw new Error(`refusing semantic snapshot ${digest}: its content address does not match`);\n }\n }\n\n await mkdir(objects, { recursive: true });\n await mkdir(manifests, { recursive: true });\n for (const digest of digests) {\n await writeObject(join(objects, `${fileDigest(digest)}.json`), run.snapshots.get(digest)!);\n }\n\n const manifest: ScenarioArchiveManifest = {\n archiveVersion: 1,\n address,\n retainUntil: policy.retainUntil,\n access: policy.access,\n deletion: policy.deletion,\n definition: run.definition,\n execution: run.execution,\n snapshots: digests,\n };\n await writeAtomic(manifestPath(manifests, address), canonicalize(asCanonical(manifest)));\n return manifest;\n },\n\n async read(address) {\n const path = manifestPath(manifests, address);\n const manifest = await readOptional<ScenarioArchiveManifest>(path);\n if (manifest === undefined) {\n return { kind: 'unobserved', because: 'no archived scenario exists at this address' };\n }\n if (parseExpiry(manifest.retainUntil) <= now().getTime()) {\n return {\n kind: 'unobserved',\n because: `the archived scenario expired at ${manifest.retainUntil}`,\n };\n }\n\n const snapshots = new Map<Digest, SemanticSnapshot>();\n for (const digest of manifest.snapshots) {\n const snapshot = await readOptional<SemanticSnapshot>(\n join(objects, `${fileDigest(digest)}.json`),\n );\n if (snapshot === undefined) {\n return {\n kind: 'unobserved',\n because: `archived semantic snapshot ${digest} is unavailable`,\n };\n }\n if (semanticSnapshotDigest(snapshot) !== digest) {\n throw new Error(`archived semantic snapshot ${digest} failed its content address`);\n }\n snapshots.set(digest, snapshot);\n }\n\n const run = checkedRun({\n definition: checkedDefinition(manifest.definition),\n execution: checkedExecution(manifest.execution),\n snapshots,\n });\n assertScenarioRun(run);\n return {\n kind: 'observed',\n manifest,\n run,\n };\n },\n\n async collectExpired() {\n const manifestFiles = await jsonFiles(manifests);\n const retained = new Set<Digest>();\n let removedManifests = 0;\n\n for (const file of manifestFiles) {\n const manifest = await readRequired<ScenarioArchiveManifest>(join(manifests, file));\n if (parseExpiry(manifest.retainUntil) <= now().getTime()) {\n await rm(join(manifests, file));\n removedManifests += 1;\n } else {\n for (const digest of manifest.snapshots) retained.add(digest);\n }\n }\n\n let removedSnapshots = 0;\n for (const file of await jsonFiles(objects)) {\n const snapshot = await readRequired<SemanticSnapshot>(join(objects, file));\n const digest = semanticSnapshotDigest(snapshot);\n if (!retained.has(digest)) {\n await rm(join(objects, file));\n removedSnapshots += 1;\n }\n }\n return { manifests: removedManifests, snapshots: removedSnapshots };\n },\n };\n}\n\nfunction observedDigests(execution: ScenarioExecutionData): readonly Digest[] {\n return [...new Set(\n execution.frames.flatMap((frame) =>\n frame.outcome.kind === 'observed' ? [frame.outcome.snapshot] : [],\n ),\n )].sort(compare);\n}\n\nfunction validateAddress(address: ScenarioArchiveAddress, run: ScenarioRun): void {\n const mismatches = [\n address.scenario === run.definition.id ? undefined : 'scenario',\n address.execution === run.execution.id ? undefined : 'execution',\n address.precondition === run.execution.precondition.id ? undefined : 'precondition',\n address.profile === run.execution.profile ? undefined : 'profile',\n ].filter((field): field is string => field !== undefined);\n if (mismatches.length > 0) {\n throw new Error(`refusing scenario archive address with mismatched ${mismatches.join(', ')}`);\n }\n for (const [field, value] of Object.entries(address)) requireDeclaration(value, field);\n}\n\nfunction parseExpiry(value: string): number {\n const timestamp = Date.parse(value);\n if (!Number.isFinite(timestamp)) throw new Error(`invalid scenario retention instant: ${value}`);\n return timestamp;\n}\n\nfunction requireDeclaration(value: string, what: string): void {\n if (value.trim() === '') throw new Error(`scenario archive ${what} must not be empty`);\n}\n\nfunction manifestPath(root: string, address: ScenarioArchiveAddress): string {\n return join(root, `${fileDigest(digestValue(asCanonical(address)))}.json`);\n}\n\nfunction fileDigest(digest: Digest): string {\n return digest.replace(':', '-');\n}\n\nfunction asCanonical(value: unknown): CanonicalValue {\n return value as CanonicalValue;\n}\n\nasync function writeObject(path: string, snapshot: SemanticSnapshot): Promise<void> {\n const content = canonicalize(asCanonical(snapshot));\n const existing = await readText(path);\n if (existing !== undefined) {\n if (existing !== content) throw new Error(`content-address collision at ${path}`);\n return;\n }\n await writeAtomic(path, content);\n}\n\nasync function writeAtomic(path: string, content: string): Promise<void> {\n await mkdir(dirname(path), { recursive: true });\n const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;\n await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });\n try {\n await rename(temporary, path);\n } catch (error) {\n await rm(temporary, { force: true });\n throw error;\n }\n}\n\nasync function readOptional<T>(path: string): Promise<T | undefined> {\n const text = await readText(path);\n return text === undefined ? undefined : (JSON.parse(text) as T);\n}\n\nasync function readRequired<T>(path: string): Promise<T> {\n const found = await readOptional<T>(path);\n if (found === undefined) throw new Error(`archive entry disappeared while reading ${path}`);\n return found;\n}\n\nasync function readText(path: string): Promise<string | undefined> {\n try {\n return await readFile(path, 'utf8');\n } catch (error) {\n if (isMissing(error)) return undefined;\n throw error;\n }\n}\n\nasync function jsonFiles(path: string): Promise<readonly string[]> {\n try {\n return (await readdir(path)).filter((file) => file.endsWith('.json')).sort(compare);\n } catch (error) {\n if (isMissing(error)) return [];\n throw error;\n }\n}\n\nfunction isMissing(error: unknown): boolean {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'code' in error &&\n (error as { readonly code?: string }).code === 'ENOENT'\n );\n}\n\nfunction compare(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"]}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ScenarioAssessment, ScenarioRun } from './contract.js';
|
|
2
|
+
/** Compare two witnessed paths without writing a verdict or mutating either execution. */
|
|
3
|
+
export declare function assessScenarios(left: ScenarioRun, right: ScenarioRun): ScenarioAssessment;
|
|
4
|
+
//# sourceMappingURL=assessment.d.ts.map
|