@variance-authority/tribunal 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 +100 -0
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/dist/__fixtures__/review.d.ts +47 -0
- package/dist/__fixtures__/review.js +145 -0
- package/dist/__fixtures__/review.js.map +1 -0
- package/dist/bindings.d.ts +134 -0
- package/dist/bindings.js +88 -0
- package/dist/bindings.js.map +1 -0
- package/dist/changelog.d.ts +132 -0
- package/dist/changelog.js +129 -0
- package/dist/changelog.js.map +1 -0
- package/dist/history-queries.d.ts +82 -0
- package/dist/history-queries.js +200 -0
- package/dist/history-queries.js.map +1 -0
- package/dist/history-rows.d.ts +42 -0
- package/dist/history-rows.js +148 -0
- package/dist/history-rows.js.map +1 -0
- package/dist/history.d.ts +40 -0
- package/dist/history.js +186 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/migration-steps.d.ts +32 -0
- package/dist/migration-steps.js +303 -0
- package/dist/migration-steps.js.map +1 -0
- package/dist/migrations.d.ts +58 -0
- package/dist/migrations.js +283 -0
- package/dist/migrations.js.map +1 -0
- package/dist/next.d.ts +94 -0
- package/dist/next.js +44 -0
- package/dist/next.js.map +1 -0
- package/dist/node/bin.d.ts +120 -0
- package/dist/node/bin.js +337 -0
- package/dist/node/bin.js.map +1 -0
- package/dist/node/bucket.d.ts +58 -0
- package/dist/node/bucket.js +142 -0
- package/dist/node/bucket.js.map +1 -0
- package/dist/node/database.d.ts +33 -0
- package/dist/node/database.js +157 -0
- package/dist/node/database.js.map +1 -0
- package/dist/node/index.d.ts +43 -0
- package/dist/node/index.js +43 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/serve.d.ts +81 -0
- package/dist/node/serve.js +193 -0
- package/dist/node/serve.js.map +1 -0
- package/dist/node/ui-assets.d.ts +47 -0
- package/dist/node/ui-assets.js +60 -0
- package/dist/node/ui-assets.js.map +1 -0
- package/dist/review-ingest.d.ts +24 -0
- package/dist/review-ingest.js +184 -0
- package/dist/review-ingest.js.map +1 -0
- package/dist/review-read.d.ts +87 -0
- package/dist/review-read.js +361 -0
- package/dist/review-read.js.map +1 -0
- package/dist/review-rows.d.ts +30 -0
- package/dist/review-rows.js +67 -0
- package/dist/review-rows.js.map +1 -0
- package/dist/review-types.d.ts +414 -0
- package/dist/review-types.js +2 -0
- package/dist/review-types.js.map +1 -0
- package/dist/review-write.d.ts +35 -0
- package/dist/review-write.js +86 -0
- package/dist/review-write.js.map +1 -0
- package/dist/review.d.ts +53 -0
- package/dist/review.fixtures.d.ts +47 -0
- package/dist/review.fixtures.js +145 -0
- package/dist/review.fixtures.js.map +1 -0
- package/dist/review.js +245 -0
- package/dist/review.js.map +1 -0
- package/dist/schema.d.ts +36 -0
- package/dist/schema.js +39 -0
- package/dist/schema.js.map +1 -0
- package/dist/store.d.ts +58 -0
- package/dist/store.js +278 -0
- package/dist/store.js.map +1 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +92 -0
- package/dist/testing.js.map +1 -0
- package/dist/ui/__probe.d.ts +3 -0
- package/dist/ui/__probe.js +5 -0
- package/dist/ui/__probe.js.map +1 -0
- package/dist/ui/attribution.d.ts +46 -0
- package/dist/ui/attribution.js +79 -0
- package/dist/ui/attribution.js.map +1 -0
- package/dist/ui/because.d.ts +51 -0
- package/dist/ui/because.js +52 -0
- package/dist/ui/because.js.map +1 -0
- package/dist/ui/browser-entry.d.ts +2 -0
- package/dist/ui/browser-entry.js +34 -0
- package/dist/ui/browser-entry.js.map +1 -0
- package/dist/ui/builds.d.ts +43 -0
- package/dist/ui/builds.js +79 -0
- package/dist/ui/builds.js.map +1 -0
- package/dist/ui/carried.d.ts +73 -0
- package/dist/ui/carried.js +63 -0
- package/dist/ui/carried.js.map +1 -0
- package/dist/ui/cause.d.ts +73 -0
- package/dist/ui/cause.js +133 -0
- package/dist/ui/cause.js.map +1 -0
- package/dist/ui/change-story.d.ts +153 -0
- package/dist/ui/change-story.js +253 -0
- package/dist/ui/change-story.js.map +1 -0
- package/dist/ui/change.d.ts +51 -0
- package/dist/ui/change.js +125 -0
- package/dist/ui/change.js.map +1 -0
- package/dist/ui/client.d.ts +74 -0
- package/dist/ui/client.js +88 -0
- package/dist/ui/client.js.map +1 -0
- package/dist/ui/consumers.d.ts +80 -0
- package/dist/ui/consumers.js +125 -0
- package/dist/ui/consumers.js.map +1 -0
- package/dist/ui/control.d.ts +63 -0
- package/dist/ui/control.js +39 -0
- package/dist/ui/control.js.map +1 -0
- package/dist/ui/crossing.d.ts +55 -0
- package/dist/ui/crossing.js +74 -0
- package/dist/ui/crossing.js.map +1 -0
- package/dist/ui/declarations.d.ts +36 -0
- package/dist/ui/declarations.js +73 -0
- package/dist/ui/declarations.js.map +1 -0
- package/dist/ui/distance.d.ts +154 -0
- package/dist/ui/distance.js +180 -0
- package/dist/ui/distance.js.map +1 -0
- package/dist/ui/divergence.d.ts +40 -0
- package/dist/ui/divergence.js +176 -0
- package/dist/ui/divergence.js.map +1 -0
- package/dist/ui/docket.d.ts +39 -0
- package/dist/ui/docket.js +172 -0
- package/dist/ui/docket.js.map +1 -0
- package/dist/ui/findings.d.ts +42 -0
- package/dist/ui/findings.js +110 -0
- package/dist/ui/findings.js.map +1 -0
- package/dist/ui/foreseen.d.ts +55 -0
- package/dist/ui/foreseen.js +91 -0
- package/dist/ui/foreseen.js.map +1 -0
- package/dist/ui/glance.d.ts +101 -0
- package/dist/ui/glance.js +101 -0
- package/dist/ui/glance.js.map +1 -0
- package/dist/ui/grouping.d.ts +158 -0
- package/dist/ui/grouping.js +187 -0
- package/dist/ui/grouping.js.map +1 -0
- package/dist/ui/handed.d.ts +60 -0
- package/dist/ui/handed.js +122 -0
- package/dist/ui/handed.js.map +1 -0
- package/dist/ui/history.d.ts +88 -0
- package/dist/ui/history.js +170 -0
- package/dist/ui/history.js.map +1 -0
- package/dist/ui/holding.d.ts +84 -0
- package/dist/ui/holding.js +90 -0
- package/dist/ui/holding.js.map +1 -0
- package/dist/ui/impact.d.ts +31 -0
- package/dist/ui/impact.js +132 -0
- package/dist/ui/impact.js.map +1 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/index.js +21 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/lead.d.ts +35 -0
- package/dist/ui/lead.js +34 -0
- package/dist/ui/lead.js.map +1 -0
- package/dist/ui/look.d.ts +109 -0
- package/dist/ui/look.js +248 -0
- package/dist/ui/look.js.map +1 -0
- package/dist/ui/mark.d.ts +11 -0
- package/dist/ui/mark.js +13 -0
- package/dist/ui/mark.js.map +1 -0
- package/dist/ui/moved.d.ts +121 -0
- package/dist/ui/moved.js +143 -0
- package/dist/ui/moved.js.map +1 -0
- package/dist/ui/navigation.d.ts +26 -0
- package/dist/ui/navigation.js +53 -0
- package/dist/ui/navigation.js.map +1 -0
- package/dist/ui/order.d.ts +113 -0
- package/dist/ui/order.js +226 -0
- package/dist/ui/order.js.map +1 -0
- package/dist/ui/origins.d.ts +42 -0
- package/dist/ui/origins.js +149 -0
- package/dist/ui/origins.js.map +1 -0
- package/dist/ui/outcome.d.ts +93 -0
- package/dist/ui/outcome.js +162 -0
- package/dist/ui/outcome.js.map +1 -0
- package/dist/ui/parted.d.ts +68 -0
- package/dist/ui/parted.js +101 -0
- package/dist/ui/parted.js.map +1 -0
- package/dist/ui/rail.d.ts +31 -0
- package/dist/ui/rail.js +21 -0
- package/dist/ui/rail.js.map +1 -0
- package/dist/ui/reach.d.ts +66 -0
- package/dist/ui/reach.js +185 -0
- package/dist/ui/reach.js.map +1 -0
- package/dist/ui/regions.d.ts +82 -0
- package/dist/ui/regions.js +67 -0
- package/dist/ui/regions.js.map +1 -0
- package/dist/ui/review.bundle.js +69 -0
- package/dist/ui/review.d.ts +97 -0
- package/dist/ui/review.js +39 -0
- package/dist/ui/review.js.map +1 -0
- package/dist/ui/root.d.ts +72 -0
- package/dist/ui/root.js +102 -0
- package/dist/ui/root.js.map +1 -0
- package/dist/ui/route.d.ts +95 -0
- package/dist/ui/route.js +114 -0
- package/dist/ui/route.js.map +1 -0
- package/dist/ui/run.d.ts +26 -0
- package/dist/ui/run.js +12 -0
- package/dist/ui/run.js.map +1 -0
- package/dist/ui/sense.d.ts +187 -0
- package/dist/ui/sense.js +222 -0
- package/dist/ui/sense.js.map +1 -0
- package/dist/ui/settled.d.ts +43 -0
- package/dist/ui/settled.js +59 -0
- package/dist/ui/settled.js.map +1 -0
- package/dist/ui/shell.d.ts +76 -0
- package/dist/ui/shell.js +65 -0
- package/dist/ui/shell.js.map +1 -0
- package/dist/ui/shift.d.ts +52 -0
- package/dist/ui/shift.js +98 -0
- package/dist/ui/shift.js.map +1 -0
- package/dist/ui/styles-docket.d.ts +18 -0
- package/dist/ui/styles-docket.js +281 -0
- package/dist/ui/styles-docket.js.map +1 -0
- package/dist/ui/styles-stage.d.ts +18 -0
- package/dist/ui/styles-stage.js +92 -0
- package/dist/ui/styles-stage.js.map +1 -0
- package/dist/ui/styles.d.ts +12 -0
- package/dist/ui/styles.js +482 -0
- package/dist/ui/styles.js.map +1 -0
- package/dist/ui/subject.d.ts +36 -0
- package/dist/ui/subject.js +138 -0
- package/dist/ui/subject.js.map +1 -0
- package/dist/ui/text.d.ts +129 -0
- package/dist/ui/text.js +164 -0
- package/dist/ui/text.js.map +1 -0
- package/dist/ui/variations.d.ts +37 -0
- package/dist/ui/variations.js +60 -0
- package/dist/ui/variations.js.map +1 -0
- package/dist/ui/viewer.d.ts +67 -0
- package/dist/ui/viewer.js +246 -0
- package/dist/ui/viewer.js.map +1 -0
- package/dist/worker-auth.d.ts +48 -0
- package/dist/worker-auth.js +74 -0
- package/dist/worker-auth.js.map +1 -0
- package/dist/worker-entry.d.ts +58 -0
- package/dist/worker-entry.js +36 -0
- package/dist/worker-entry.js.map +1 -0
- package/dist/worker-http.d.ts +39 -0
- package/dist/worker-http.js +111 -0
- package/dist/worker-http.js.map +1 -0
- package/dist/worker-input.d.ts +81 -0
- package/dist/worker-input.js +296 -0
- package/dist/worker-input.js.map +1 -0
- package/dist/worker.d.ts +91 -0
- package/dist/worker.js +287 -0
- package/dist/worker.js.map +1 -0
- package/mark.svg +30 -0
- package/migrations/0001_initial.sql +193 -0
- package/migrations/0002_changelog.sql +30 -0
- package/migrations/0003_accessibility.sql +8 -0
- package/migrations/0004_variations.sql +19 -0
- package/migrations/0005_reach.sql +26 -0
- package/migrations/0006_baseline-size.sql +7 -0
- package/migrations/0007_declarations.sql +7 -0
- package/migrations/0008_green-because.sql +7 -0
- package/migrations/0009_moved-bands.sql +6 -0
- package/migrations/0010_composition.sql +15 -0
- package/migrations/0011_movements.sql +21 -0
- package/migrations/0012_control-size.sql +6 -0
- package/package.json +110 -0
- package/wrangler.jsonc +45 -0
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type RasterStore } from '@variance-authority/raster';
|
|
2
|
+
import { type TribunalBindings } from './bindings.js';
|
|
3
|
+
/**
|
|
4
|
+
* Baselines in R2, attributed in D1.
|
|
5
|
+
*
|
|
6
|
+
* The third durable backend, after the directory and git-LFS, and it answers to
|
|
7
|
+
* the same contract for the same reason the other two do: what a baseline
|
|
8
|
+
* *means* lives in [`@variance-authority/raster`](../../raster), so a verdict
|
|
9
|
+
* cannot depend on where the bytes were kept. That claim is tested rather than
|
|
10
|
+
* asserted — this store is one of the implementations in
|
|
11
|
+
* [`observe/parity.test.ts`](../../observe/src/parity.test.ts), which pins each
|
|
12
|
+
* expected verdict as well as comparing the backends, because three stores
|
|
13
|
+
* agreeing on a wrong answer is not a pass.
|
|
14
|
+
*
|
|
15
|
+
* ## The split, and why it is not one store
|
|
16
|
+
*
|
|
17
|
+
* D1 holds the sidecar; R2 holds the image. That is not two stores pretending to
|
|
18
|
+
* be one — it is the directory store's `.json`/`.png` pair with the halves in the
|
|
19
|
+
* services that suit them. Text that gets queried across identities goes where
|
|
20
|
+
* queries are cheap; bytes that only ever get fetched whole go where bytes are
|
|
21
|
+
* cheap.
|
|
22
|
+
*
|
|
23
|
+
* It inherits the pair's failure mode exactly, and treats it the same way: a row
|
|
24
|
+
* whose object is gone, or an object whose row is gone, is **damage**, and damage
|
|
25
|
+
* is never reported as absence. `null` is earned by one outcome only — no row —
|
|
26
|
+
* because `null` becomes `new`, `new` records whatever this build painted, and
|
|
27
|
+
* the image it overwrites was the only evidence of what the subject looked like
|
|
28
|
+
* before.
|
|
29
|
+
*
|
|
30
|
+
* ## Write order is the safety property
|
|
31
|
+
*
|
|
32
|
+
* `put` writes the object, then the row. The two orders fail differently and only
|
|
33
|
+
* one of them fails safely:
|
|
34
|
+
*
|
|
35
|
+
* - **object first** — a crash between them leaves an object nothing points at.
|
|
36
|
+
* Invisible to every read, removed by the next sweep, costs storage.
|
|
37
|
+
* - **row first** — a crash between them leaves a row pointing at nothing, which
|
|
38
|
+
* is a baseline that throws on every subsequent run until a person deletes it.
|
|
39
|
+
*
|
|
40
|
+
* D1 and R2 are separate services with no transaction between them, so one of
|
|
41
|
+
* these happens; this file chooses the one that costs bytes over the one that
|
|
42
|
+
* stops the suite.
|
|
43
|
+
*/
|
|
44
|
+
export interface BucketStoreOptions extends TribunalBindings {
|
|
45
|
+
/**
|
|
46
|
+
* The project these baselines belong to.
|
|
47
|
+
*
|
|
48
|
+
* Every row and every object key is scoped by it, so one deployment serves
|
|
49
|
+
* several repositories without their `story:card` colliding. There is no
|
|
50
|
+
* default: a store that invented one would put two projects' baselines in one
|
|
51
|
+
* namespace and the first symptom would be a mass `changed`.
|
|
52
|
+
*/
|
|
53
|
+
readonly project: string;
|
|
54
|
+
/** Injected so tests can pin `at`. Defaults to the wall clock. */
|
|
55
|
+
readonly now?: () => Date;
|
|
56
|
+
}
|
|
57
|
+
export declare function createBucketStore(options: BucketStoreOptions): RasterStore;
|
|
58
|
+
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { identityDigest } from '@variance-authority/core';
|
|
2
|
+
import { REFUSAL, RasterStoreError, messageOf, neverFails, sidecarFrom, } from '@variance-authority/raster';
|
|
3
|
+
import { base64Of, bytesOf, requireD1, requireR2, } from './bindings.js';
|
|
4
|
+
/**
|
|
5
|
+
* Refuse a deployment that never said which project it is.
|
|
6
|
+
*
|
|
7
|
+
* Checked here rather than left to the type, because the operator who gets this
|
|
8
|
+
* wrong reads the value out of an environment or a config file, where the
|
|
9
|
+
* compiler is not standing. Two of `createTribunal`'s five required options
|
|
10
|
+
* already answer with a sentence; this one answered `could not reach its
|
|
11
|
+
* database or its bucket` from the first request that touched a baseline, which
|
|
12
|
+
* blames the platform for a line in a wrangler file.
|
|
13
|
+
*
|
|
14
|
+
* Blank is refused with absent. A project is the namespace every row and object
|
|
15
|
+
* key is scoped by, and one nobody named is the invented default this option
|
|
16
|
+
* exists to make impossible.
|
|
17
|
+
*/
|
|
18
|
+
function requireProject(project) {
|
|
19
|
+
if (typeof project !== 'string' || project.trim() === '') {
|
|
20
|
+
throw new Error('`project` is required and must not be blank. It scopes every row and every object key, ' +
|
|
21
|
+
'so one deployment can serve several repositories without their `story:card` colliding; ' +
|
|
22
|
+
'a deployment that never named itself puts them in one namespace, and the first symptom ' +
|
|
23
|
+
'is a mass `changed`');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function createBucketStore(options) {
|
|
27
|
+
const { db, bucket, project } = options;
|
|
28
|
+
requireD1(db);
|
|
29
|
+
requireR2(bucket);
|
|
30
|
+
requireProject(project);
|
|
31
|
+
const now = options.now ?? (() => new Date());
|
|
32
|
+
return {
|
|
33
|
+
retention: 'durable',
|
|
34
|
+
async find(key, identity) {
|
|
35
|
+
const mine = identityDigest(identity);
|
|
36
|
+
const row = await locate(db, project, key, mine);
|
|
37
|
+
if (row === null)
|
|
38
|
+
return null;
|
|
39
|
+
const sidecar = parse(row.sidecar, describeRow(project, key, row.identityDigest));
|
|
40
|
+
return {
|
|
41
|
+
raster: { ...sidecar, bytes: await fetchBytes(bucket, row.sidecar.object_key) },
|
|
42
|
+
comparable: row.identityDigest === mine,
|
|
43
|
+
storedUnder: sidecar.identity,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
async describe(key, identity) {
|
|
47
|
+
const mine = identityDigest(identity);
|
|
48
|
+
const row = await locate(db, project, key, mine);
|
|
49
|
+
if (row === null)
|
|
50
|
+
return null;
|
|
51
|
+
const where = describeRow(project, key, row.identityDigest);
|
|
52
|
+
const sidecar = parse(row.sidecar, where);
|
|
53
|
+
// The object is still checked for existence, and this is the entire reason
|
|
54
|
+
// `R2Like` has a `head`. Answering from the row alone would let `describe`
|
|
55
|
+
// and `find` disagree about whether a baseline exists, and a verdict that
|
|
56
|
+
// depends on which of the two a caller asked is not a verdict.
|
|
57
|
+
if ((await guard(() => bucket.head(row.sidecar.object_key), where)) === null) {
|
|
58
|
+
throw halfAPair(where, row.sidecar.object_key, 'row');
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
documentDigest: sidecar.documentDigest,
|
|
62
|
+
comparable: row.identityDigest === mine,
|
|
63
|
+
storedUnder: sidecar.identity,
|
|
64
|
+
missingFonts: sidecar.missingFonts,
|
|
65
|
+
...(sidecar.components === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { components: sidecar.components.map((hash) => hash.component) }),
|
|
68
|
+
...(sidecar.accessibility === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { accessibility: sidecar.accessibility }),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
async put(key, raster) {
|
|
74
|
+
const label = labelOf(key);
|
|
75
|
+
const digest = identityDigest(raster.identity);
|
|
76
|
+
const objectKey = baselineKey(project, digest, key);
|
|
77
|
+
const where = describeRow(project, key, digest);
|
|
78
|
+
const at = now().toISOString();
|
|
79
|
+
await guard(() => bucket.put(objectKey, bytesOf(raster.bytes)), where);
|
|
80
|
+
await guard(() => db
|
|
81
|
+
.prepare(`INSERT INTO baselines
|
|
82
|
+
(project, identity_digest, subject, label, identity, document_digest,
|
|
83
|
+
width, height, missing_fonts, accessibility, object_key, at, at_ms)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
85
|
+
ON CONFLICT (project, identity_digest, subject, label) DO UPDATE SET
|
|
86
|
+
identity = excluded.identity,
|
|
87
|
+
document_digest = excluded.document_digest,
|
|
88
|
+
width = excluded.width,
|
|
89
|
+
height = excluded.height,
|
|
90
|
+
missing_fonts = excluded.missing_fonts,
|
|
91
|
+
accessibility = excluded.accessibility,
|
|
92
|
+
object_key = excluded.object_key,
|
|
93
|
+
at = excluded.at,
|
|
94
|
+
at_ms = excluded.at_ms`)
|
|
95
|
+
.bind(project, digest, key.subject, label, JSON.stringify(raster.identity), raster.documentDigest, raster.width, raster.height, JSON.stringify(raster.missingFonts), raster.accessibility === undefined ? null : JSON.stringify(raster.accessibility), objectKey, at, Date.parse(at))
|
|
96
|
+
.run(), where);
|
|
97
|
+
},
|
|
98
|
+
// Wrapped, so a bucket outage costs this run its renders and not its exit
|
|
99
|
+
// code. The comment that stood here argued the other way — that answering
|
|
100
|
+
// "miss" to an outage turns a broken bucket into a run that is merely slow —
|
|
101
|
+
// and a run that is merely slow is the correct outcome: nothing about the
|
|
102
|
+
// bucket being unreachable changes what any verdict should be. The baseline
|
|
103
|
+
// half above still refuses out loud, where an outage genuinely would.
|
|
104
|
+
renderCache: neverFails({
|
|
105
|
+
async get(digest, identity) {
|
|
106
|
+
const where = `the render cache for document ${digest}`;
|
|
107
|
+
const row = await guard(() => db
|
|
108
|
+
.prepare(`SELECT identity, document_digest, width, height, missing_fonts, object_key
|
|
109
|
+
FROM render_cache
|
|
110
|
+
WHERE project = ? AND identity_digest = ? AND document_digest = ?`)
|
|
111
|
+
.bind(project, identityDigest(identity), digest)
|
|
112
|
+
.first(), where);
|
|
113
|
+
if (row === null)
|
|
114
|
+
return null;
|
|
115
|
+
return { ...parse(row, where), bytes: await fetchBytes(bucket, row.object_key) };
|
|
116
|
+
},
|
|
117
|
+
async put(raster) {
|
|
118
|
+
const digest = identityDigest(raster.identity);
|
|
119
|
+
const where = `the render cache for document ${raster.documentDigest}`;
|
|
120
|
+
const objectKey = `${project}/cache/${digest}/${raster.documentDigest}.png`;
|
|
121
|
+
await guard(() => bucket.put(objectKey, bytesOf(raster.bytes)), where);
|
|
122
|
+
await guard(() => db
|
|
123
|
+
.prepare(`INSERT OR REPLACE INTO render_cache
|
|
124
|
+
(project, identity_digest, document_digest, identity, width, height,
|
|
125
|
+
missing_fonts, object_key, at_ms)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
127
|
+
.bind(project, digest, raster.documentDigest, JSON.stringify(raster.identity), raster.width, raster.height, JSON.stringify(raster.missingFonts), objectKey, Date.now())
|
|
128
|
+
.run(), where);
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The row for this key under this identity, or the most recent one under any
|
|
135
|
+
* other, or nothing.
|
|
136
|
+
*
|
|
137
|
+
* The lookup is deliberately *not* scoped to the caller's identity, which is the
|
|
138
|
+
* `RasterStore` contract's own instruction: returning nothing for a baseline that
|
|
139
|
+
* exists but was written elsewhere reports "we have never seen this", and "we have
|
|
140
|
+
* seen this, on a machine you are not" is a different and much more useful
|
|
141
|
+
* sentence — it is the one that produces `incomparable` instead of a silent
|
|
142
|
+
* re-record.
|
|
143
|
+
*
|
|
144
|
+
* Where the directory store scans siblings in whatever order `readdir` returns,
|
|
145
|
+
* this takes the most recently written. Both satisfy the contract; only one of
|
|
146
|
+
* them gives the same answer twice when three machines have written the subject,
|
|
147
|
+
* and an `incomparable` message that names a different machine on each run is a
|
|
148
|
+
* message nobody can act on.
|
|
149
|
+
*/
|
|
150
|
+
async function locate(db, project, key, mine) {
|
|
151
|
+
const label = labelOf(key);
|
|
152
|
+
const where = describeRow(project, key, mine);
|
|
153
|
+
const row = await guard(() => db
|
|
154
|
+
.prepare(`SELECT identity, document_digest, width, height, missing_fonts, accessibility, object_key,
|
|
155
|
+
identity_digest
|
|
156
|
+
FROM baselines
|
|
157
|
+
WHERE project = ? AND subject = ? AND label = ?
|
|
158
|
+
ORDER BY (identity_digest = ?) DESC, at_ms DESC
|
|
159
|
+
LIMIT 1`)
|
|
160
|
+
.bind(project, key.subject, label, mine)
|
|
161
|
+
.first(), where);
|
|
162
|
+
return row === null ? null : { sidecar: row, identityDigest: row.identity_digest };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The bytes a row says are there.
|
|
166
|
+
*
|
|
167
|
+
* An absent object is never a miss. The row is the store positively saying a
|
|
168
|
+
* baseline exists; if the bytes are gone, what happened is that half a baseline
|
|
169
|
+
* was destroyed, and reporting that as `new` would destroy the other half while
|
|
170
|
+
* printing success.
|
|
171
|
+
*/
|
|
172
|
+
async function fetchBytes(bucket, objectKey) {
|
|
173
|
+
const object = await guard(() => bucket.get(objectKey), `the baseline object ${objectKey}`);
|
|
174
|
+
if (object === null)
|
|
175
|
+
throw halfAPair(`the baseline object ${objectKey}`, objectKey, 'row');
|
|
176
|
+
return base64Of(await guard(() => object.arrayBuffer(), `the baseline object ${objectKey}`));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A sidecar row, checked rather than cast.
|
|
180
|
+
*
|
|
181
|
+
* The columns came out of a database this process did not write in this run —
|
|
182
|
+
* one that may have been written by an older version of this package, or by a
|
|
183
|
+
* `put` that raced a schema change. Believed unchecked, a malformed `identity`
|
|
184
|
+
* becomes a digest taken over nothing and a missing `documentDigest` becomes
|
|
185
|
+
* `undefined` compared against a real one: two invented answers where the honest
|
|
186
|
+
* one is that the record cannot be read.
|
|
187
|
+
*/
|
|
188
|
+
function parse(row, where) {
|
|
189
|
+
let identity;
|
|
190
|
+
let missingFonts;
|
|
191
|
+
let accessibility;
|
|
192
|
+
try {
|
|
193
|
+
identity = JSON.parse(row.identity);
|
|
194
|
+
missingFonts = JSON.parse(row.missing_fonts);
|
|
195
|
+
accessibility = row.accessibility == null ? undefined : JSON.parse(row.accessibility);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
throw new RasterStoreError(`${where} holds JSON columns that will not parse: ${messageOf(error)}. ${REFUSAL}.`, { cause: error });
|
|
199
|
+
}
|
|
200
|
+
const sidecar = sidecarFrom({
|
|
201
|
+
identity,
|
|
202
|
+
missingFonts,
|
|
203
|
+
documentDigest: row.document_digest,
|
|
204
|
+
width: row.width,
|
|
205
|
+
height: row.height,
|
|
206
|
+
...(accessibility === undefined ? {} : { accessibility }),
|
|
207
|
+
});
|
|
208
|
+
if (sidecar === null) {
|
|
209
|
+
throw new RasterStoreError(`${where} is not a raster record: it parsed, but does not carry a document digest and ` +
|
|
210
|
+
`the identity that painted it. ${REFUSAL}.`);
|
|
211
|
+
}
|
|
212
|
+
return sidecar;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Every call into D1 and R2 goes through here, and every failure of one becomes
|
|
216
|
+
* an operator error.
|
|
217
|
+
*
|
|
218
|
+
* Not a convenience. A `TypeError` from a binding that was never wired, a 500
|
|
219
|
+
* from a bucket, a D1 statement refused because the schema is a version behind —
|
|
220
|
+
* each of them would otherwise propagate as some other kind of exception, and the
|
|
221
|
+
* one thing that must never happen is that any of them is caught somewhere above
|
|
222
|
+
* and read as "no baseline".
|
|
223
|
+
*/
|
|
224
|
+
async function guard(call, where) {
|
|
225
|
+
try {
|
|
226
|
+
return await call();
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw new RasterStoreError(`the baseline store could not reach its database or its bucket for ${where}: ` +
|
|
230
|
+
`${messageOf(error)}. ${REFUSAL}.`, { cause: error });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One half of the pair without the other.
|
|
235
|
+
*
|
|
236
|
+
* The directory store's `halfAPair`, in the vocabulary of two services rather
|
|
237
|
+
* than two files, and it exists for the same reason: a row with no object and an
|
|
238
|
+
* object with no row both look exactly like a subject nobody has rendered, if the
|
|
239
|
+
* lookup is willing to shrug.
|
|
240
|
+
*/
|
|
241
|
+
function halfAPair(where, objectKey, survivor) {
|
|
242
|
+
return new RasterStoreError(`${where} is half there: the ${survivor} exists and the ` +
|
|
243
|
+
`${survivor === 'row' ? 'object' : 'row'} does not. A baseline is the pair, so one without ` +
|
|
244
|
+
`the other is a corrupted baseline rather than a missing one. ${REFUSAL}. Restore ` +
|
|
245
|
+
`\`${objectKey}\` in the bucket, or delete the row to record the subject afresh.`);
|
|
246
|
+
}
|
|
247
|
+
function baselineKey(project, identity, key) {
|
|
248
|
+
const name = key.label === undefined ? key.subject : `${key.subject}__${key.label}`;
|
|
249
|
+
return `${project}/baselines/${identity}/${encodeURIComponent(name)}.png`;
|
|
250
|
+
}
|
|
251
|
+
function describeRow(project, key, identity) {
|
|
252
|
+
const name = key.label === undefined ? key.subject : `${key.subject} (${key.label})`;
|
|
253
|
+
return `the baseline for ${name} of project ${project} under identity ${identity}`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The label as the database stores it, with absence as the empty string — and an
|
|
257
|
+
* empty *supplied* label refused rather than folded into it.
|
|
258
|
+
*
|
|
259
|
+
* SQLite permits NULL in the columns of an ordinary PRIMARY KEY, so a nullable
|
|
260
|
+
* `label` would make two unlabelled baselines for one subject two rows that never
|
|
261
|
+
* collide: the uniqueness that stops a second `put` from replacing the first would
|
|
262
|
+
* silently not exist, and the store would grow a new baseline per run while
|
|
263
|
+
* reporting `unchanged` against whichever one it happened to read.
|
|
264
|
+
*
|
|
265
|
+
* That forces one encoding to mean two things, so the second one is refused at
|
|
266
|
+
* the door. `{ subject: 's', label: '' }` is a caller bug either way; the choice
|
|
267
|
+
* is between diagnosing it and quietly serving it another subject's image.
|
|
268
|
+
*/
|
|
269
|
+
function labelOf(key) {
|
|
270
|
+
if (key.label === '') {
|
|
271
|
+
throw new RasterStoreError('a baseline was addressed with an empty label. This store encodes "no label" as the ' +
|
|
272
|
+
'empty string, so an empty label and no label would be the same baseline — and two ' +
|
|
273
|
+
'subjects sharing one row is a comparison against the wrong image. Pass no `label` ' +
|
|
274
|
+
`at all, or a label with something in it. ${REFUSAL}.`);
|
|
275
|
+
}
|
|
276
|
+
return key.label ?? '';
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { identityDigest, type Digest, type Raster } from '@variance-authority/core';\nimport {\n REFUSAL,\n RasterStoreError,\n messageOf,\n neverFails,\n sidecarFrom,\n type BaselineKey,\n type Described,\n type Found,\n type RasterStore,\n} from '@variance-authority/raster';\nimport {\n base64Of,\n bytesOf,\n requireD1,\n requireR2,\n type D1Like,\n type R2Like,\n type TribunalBindings,\n} from './bindings.js';\n\n/**\n * Baselines in R2, attributed in D1.\n *\n * The third durable backend, after the directory and git-LFS, and it answers to\n * the same contract for the same reason the other two do: what a baseline\n * *means* lives in [`@variance-authority/raster`](../../raster), so a verdict\n * cannot depend on where the bytes were kept. That claim is tested rather than\n * asserted — this store is one of the implementations in\n * [`observe/parity.test.ts`](../../observe/src/parity.test.ts), which pins each\n * expected verdict as well as comparing the backends, because three stores\n * agreeing on a wrong answer is not a pass.\n *\n * ## The split, and why it is not one store\n *\n * D1 holds the sidecar; R2 holds the image. That is not two stores pretending to\n * be one — it is the directory store's `.json`/`.png` pair with the halves in the\n * services that suit them. Text that gets queried across identities goes where\n * queries are cheap; bytes that only ever get fetched whole go where bytes are\n * cheap.\n *\n * It inherits the pair's failure mode exactly, and treats it the same way: a row\n * whose object is gone, or an object whose row is gone, is **damage**, and damage\n * is never reported as absence. `null` is earned by one outcome only — no row —\n * because `null` becomes `new`, `new` records whatever this build painted, and\n * the image it overwrites was the only evidence of what the subject looked like\n * before.\n *\n * ## Write order is the safety property\n *\n * `put` writes the object, then the row. The two orders fail differently and only\n * one of them fails safely:\n *\n * - **object first** — a crash between them leaves an object nothing points at.\n * Invisible to every read, removed by the next sweep, costs storage.\n * - **row first** — a crash between them leaves a row pointing at nothing, which\n * is a baseline that throws on every subsequent run until a person deletes it.\n *\n * D1 and R2 are separate services with no transaction between them, so one of\n * these happens; this file chooses the one that costs bytes over the one that\n * stops the suite.\n */\n\nexport interface BucketStoreOptions extends TribunalBindings {\n /**\n * The project these baselines belong to.\n *\n * Every row and every object key is scoped by it, so one deployment serves\n * several repositories without their `story:card` colliding. There is no\n * default: a store that invented one would put two projects' baselines in one\n * namespace and the first symptom would be a mass `changed`.\n */\n readonly project: string;\n\n /** Injected so tests can pin `at`. Defaults to the wall clock. */\n readonly now?: () => Date;\n}\n\n/**\n * Refuse a deployment that never said which project it is.\n *\n * Checked here rather than left to the type, because the operator who gets this\n * wrong reads the value out of an environment or a config file, where the\n * compiler is not standing. Two of `createTribunal`'s five required options\n * already answer with a sentence; this one answered `could not reach its\n * database or its bucket` from the first request that touched a baseline, which\n * blames the platform for a line in a wrangler file.\n *\n * Blank is refused with absent. A project is the namespace every row and object\n * key is scoped by, and one nobody named is the invented default this option\n * exists to make impossible.\n */\nfunction requireProject(project: string): void {\n if (typeof project !== 'string' || project.trim() === '') {\n throw new Error(\n '`project` is required and must not be blank. It scopes every row and every object key, ' +\n 'so one deployment can serve several repositories without their `story:card` colliding; ' +\n 'a deployment that never named itself puts them in one namespace, and the first symptom ' +\n 'is a mass `changed`',\n );\n }\n}\n\n/** Shape of every baseline and cache row as it comes back out of D1. */\ninterface SidecarRow {\n readonly identity: string;\n readonly document_digest: string;\n readonly width: number;\n readonly height: number;\n readonly missing_fonts: string;\n readonly object_key: string;\n readonly accessibility?: string | null;\n}\n\nexport function createBucketStore(options: BucketStoreOptions): RasterStore {\n const { db, bucket, project } = options;\n requireD1(db);\n requireR2(bucket);\n requireProject(project);\n const now = options.now ?? ((): Date => new Date());\n\n return {\n retention: 'durable',\n\n async find(key, identity): Promise<Found | null> {\n const mine = identityDigest(identity);\n const row = await locate(db, project, key, mine);\n if (row === null) return null;\n\n const sidecar = parse(row.sidecar, describeRow(project, key, row.identityDigest));\n return {\n raster: { ...sidecar, bytes: await fetchBytes(bucket, row.sidecar.object_key) },\n comparable: row.identityDigest === mine,\n storedUnder: sidecar.identity,\n };\n },\n\n async describe(key, identity): Promise<Described | null> {\n const mine = identityDigest(identity);\n const row = await locate(db, project, key, mine);\n if (row === null) return null;\n\n const where = describeRow(project, key, row.identityDigest);\n const sidecar = parse(row.sidecar, where);\n\n // The object is still checked for existence, and this is the entire reason\n // `R2Like` has a `head`. Answering from the row alone would let `describe`\n // and `find` disagree about whether a baseline exists, and a verdict that\n // depends on which of the two a caller asked is not a verdict.\n if ((await guard(() => bucket.head(row.sidecar.object_key), where)) === null) {\n throw halfAPair(where, row.sidecar.object_key, 'row');\n }\n\n return {\n documentDigest: sidecar.documentDigest,\n comparable: row.identityDigest === mine,\n storedUnder: sidecar.identity,\n missingFonts: sidecar.missingFonts,\n ...(sidecar.components === undefined\n ? {}\n : { components: sidecar.components.map((hash) => hash.component) }),\n ...(sidecar.accessibility === undefined\n ? {}\n : { accessibility: sidecar.accessibility }),\n };\n },\n\n async put(key, raster): Promise<void> {\n const label = labelOf(key);\n const digest = identityDigest(raster.identity);\n const objectKey = baselineKey(project, digest, key);\n const where = describeRow(project, key, digest);\n const at = now().toISOString();\n\n await guard(() => bucket.put(objectKey, bytesOf(raster.bytes)), where);\n await guard(\n () =>\n db\n .prepare(\n `INSERT INTO baselines\n (project, identity_digest, subject, label, identity, document_digest,\n width, height, missing_fonts, accessibility, object_key, at, at_ms)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (project, identity_digest, subject, label) DO UPDATE SET\n identity = excluded.identity,\n document_digest = excluded.document_digest,\n width = excluded.width,\n height = excluded.height,\n missing_fonts = excluded.missing_fonts,\n accessibility = excluded.accessibility,\n object_key = excluded.object_key,\n at = excluded.at,\n at_ms = excluded.at_ms`,\n )\n .bind(\n project,\n digest,\n key.subject,\n label,\n JSON.stringify(raster.identity),\n raster.documentDigest,\n raster.width,\n raster.height,\n JSON.stringify(raster.missingFonts),\n raster.accessibility === undefined ? null : JSON.stringify(raster.accessibility),\n objectKey,\n at,\n Date.parse(at),\n )\n .run(),\n where,\n );\n },\n\n // Wrapped, so a bucket outage costs this run its renders and not its exit\n // code. The comment that stood here argued the other way — that answering\n // \"miss\" to an outage turns a broken bucket into a run that is merely slow —\n // and a run that is merely slow is the correct outcome: nothing about the\n // bucket being unreachable changes what any verdict should be. The baseline\n // half above still refuses out loud, where an outage genuinely would.\n renderCache: neverFails({\n async get(digest, identity): Promise<Raster | null> {\n const where = `the render cache for document ${digest}`;\n const row = await guard(\n () =>\n db\n .prepare(\n `SELECT identity, document_digest, width, height, missing_fonts, object_key\n FROM render_cache\n WHERE project = ? AND identity_digest = ? AND document_digest = ?`,\n )\n .bind(project, identityDigest(identity), digest)\n .first<SidecarRow>(),\n where,\n );\n if (row === null) return null;\n\n return { ...parse(row, where), bytes: await fetchBytes(bucket, row.object_key) };\n },\n\n async put(raster): Promise<void> {\n const digest = identityDigest(raster.identity);\n const where = `the render cache for document ${raster.documentDigest}`;\n const objectKey = `${project}/cache/${digest}/${raster.documentDigest}.png`;\n\n await guard(() => bucket.put(objectKey, bytesOf(raster.bytes)), where);\n await guard(\n () =>\n db\n .prepare(\n `INSERT OR REPLACE INTO render_cache\n (project, identity_digest, document_digest, identity, width, height,\n missing_fonts, object_key, at_ms)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n )\n .bind(\n project,\n digest,\n raster.documentDigest,\n JSON.stringify(raster.identity),\n raster.width,\n raster.height,\n JSON.stringify(raster.missingFonts),\n objectKey,\n Date.now(),\n )\n .run(),\n where,\n );\n },\n }),\n };\n}\n\n/**\n * The row for this key under this identity, or the most recent one under any\n * other, or nothing.\n *\n * The lookup is deliberately *not* scoped to the caller's identity, which is the\n * `RasterStore` contract's own instruction: returning nothing for a baseline that\n * exists but was written elsewhere reports \"we have never seen this\", and \"we have\n * seen this, on a machine you are not\" is a different and much more useful\n * sentence — it is the one that produces `incomparable` instead of a silent\n * re-record.\n *\n * Where the directory store scans siblings in whatever order `readdir` returns,\n * this takes the most recently written. Both satisfy the contract; only one of\n * them gives the same answer twice when three machines have written the subject,\n * and an `incomparable` message that names a different machine on each run is a\n * message nobody can act on.\n */\nasync function locate(\n db: D1Like,\n project: string,\n key: BaselineKey,\n mine: Digest,\n): Promise<{ readonly sidecar: SidecarRow; readonly identityDigest: string } | null> {\n const label = labelOf(key);\n const where = describeRow(project, key, mine);\n\n const row = await guard(\n () =>\n db\n .prepare(\n `SELECT identity, document_digest, width, height, missing_fonts, accessibility, object_key,\n identity_digest\n FROM baselines\n WHERE project = ? AND subject = ? AND label = ?\n ORDER BY (identity_digest = ?) DESC, at_ms DESC\n LIMIT 1`,\n )\n .bind(project, key.subject, label, mine)\n .first<SidecarRow & { readonly identity_digest: string }>(),\n where,\n );\n\n return row === null ? null : { sidecar: row, identityDigest: row.identity_digest };\n}\n\n/**\n * The bytes a row says are there.\n *\n * An absent object is never a miss. The row is the store positively saying a\n * baseline exists; if the bytes are gone, what happened is that half a baseline\n * was destroyed, and reporting that as `new` would destroy the other half while\n * printing success.\n */\nasync function fetchBytes(bucket: R2Like, objectKey: string): Promise<string> {\n const object = await guard(() => bucket.get(objectKey), `the baseline object ${objectKey}`);\n if (object === null) throw halfAPair(`the baseline object ${objectKey}`, objectKey, 'row');\n return base64Of(await guard(() => object.arrayBuffer(), `the baseline object ${objectKey}`));\n}\n\n/**\n * A sidecar row, checked rather than cast.\n *\n * The columns came out of a database this process did not write in this run —\n * one that may have been written by an older version of this package, or by a\n * `put` that raced a schema change. Believed unchecked, a malformed `identity`\n * becomes a digest taken over nothing and a missing `documentDigest` becomes\n * `undefined` compared against a real one: two invented answers where the honest\n * one is that the record cannot be read.\n */\nfunction parse(row: SidecarRow, where: string): Omit<Raster, 'bytes'> {\n let identity: unknown;\n let missingFonts: unknown;\n let accessibility: unknown;\n try {\n identity = JSON.parse(row.identity) as unknown;\n missingFonts = JSON.parse(row.missing_fonts) as unknown;\n accessibility = row.accessibility == null ? undefined : (JSON.parse(row.accessibility) as unknown);\n } catch (error) {\n throw new RasterStoreError(\n `${where} holds JSON columns that will not parse: ${messageOf(error)}. ${REFUSAL}.`,\n { cause: error },\n );\n }\n\n const sidecar = sidecarFrom({\n identity,\n missingFonts,\n documentDigest: row.document_digest,\n width: row.width,\n height: row.height,\n ...(accessibility === undefined ? {} : { accessibility }),\n });\n\n if (sidecar === null) {\n throw new RasterStoreError(\n `${where} is not a raster record: it parsed, but does not carry a document digest and ` +\n `the identity that painted it. ${REFUSAL}.`,\n );\n }\n return sidecar;\n}\n\n/**\n * Every call into D1 and R2 goes through here, and every failure of one becomes\n * an operator error.\n *\n * Not a convenience. A `TypeError` from a binding that was never wired, a 500\n * from a bucket, a D1 statement refused because the schema is a version behind —\n * each of them would otherwise propagate as some other kind of exception, and the\n * one thing that must never happen is that any of them is caught somewhere above\n * and read as \"no baseline\".\n */\nasync function guard<T>(call: () => Promise<T>, where: string): Promise<T> {\n try {\n return await call();\n } catch (error) {\n throw new RasterStoreError(\n `the baseline store could not reach its database or its bucket for ${where}: ` +\n `${messageOf(error)}. ${REFUSAL}.`,\n { cause: error },\n );\n }\n}\n\n/**\n * One half of the pair without the other.\n *\n * The directory store's `halfAPair`, in the vocabulary of two services rather\n * than two files, and it exists for the same reason: a row with no object and an\n * object with no row both look exactly like a subject nobody has rendered, if the\n * lookup is willing to shrug.\n */\nfunction halfAPair(where: string, objectKey: string, survivor: 'row' | 'object'): RasterStoreError {\n return new RasterStoreError(\n `${where} is half there: the ${survivor} exists and the ` +\n `${survivor === 'row' ? 'object' : 'row'} does not. A baseline is the pair, so one without ` +\n `the other is a corrupted baseline rather than a missing one. ${REFUSAL}. Restore ` +\n `\\`${objectKey}\\` in the bucket, or delete the row to record the subject afresh.`,\n );\n}\n\nfunction baselineKey(project: string, identity: Digest, key: BaselineKey): string {\n const name = key.label === undefined ? key.subject : `${key.subject}__${key.label}`;\n return `${project}/baselines/${identity}/${encodeURIComponent(name)}.png`;\n}\n\nfunction describeRow(project: string, key: BaselineKey, identity: string): string {\n const name = key.label === undefined ? key.subject : `${key.subject} (${key.label})`;\n return `the baseline for ${name} of project ${project} under identity ${identity}`;\n}\n\n/**\n * The label as the database stores it, with absence as the empty string — and an\n * empty *supplied* label refused rather than folded into it.\n *\n * SQLite permits NULL in the columns of an ordinary PRIMARY KEY, so a nullable\n * `label` would make two unlabelled baselines for one subject two rows that never\n * collide: the uniqueness that stops a second `put` from replacing the first would\n * silently not exist, and the store would grow a new baseline per run while\n * reporting `unchanged` against whichever one it happened to read.\n *\n * That forces one encoding to mean two things, so the second one is refused at\n * the door. `{ subject: 's', label: '' }` is a caller bug either way; the choice\n * is between diagnosing it and quietly serving it another subject's image.\n */\nfunction labelOf(key: BaselineKey): string {\n if (key.label === '') {\n throw new RasterStoreError(\n 'a baseline was addressed with an empty label. This store encodes \"no label\" as the ' +\n 'empty string, so an empty label and no label would be the same baseline — and two ' +\n 'subjects sharing one row is a comparison against the wrong image. Pass no `label` ' +\n `at all, or a label with something in it. ${REFUSAL}.`,\n );\n }\n return key.label ?? '';\n}\n"]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type R2Like } from './bindings.js';
|
|
2
|
+
import { type SqliteDatabase } from './node/database.js';
|
|
3
|
+
/**
|
|
4
|
+
* A database with the schema already applied, and a way to let go of it.
|
|
5
|
+
*
|
|
6
|
+
* The same shape [`node/database.ts`](./node/database.ts) hands a running
|
|
7
|
+
* service — this is that adapter, over `':memory:'`, with the schema applied
|
|
8
|
+
* unconditionally because a fresh in-memory file is never anything else.
|
|
9
|
+
*/
|
|
10
|
+
export type SqliteD1 = SqliteDatabase;
|
|
11
|
+
/**
|
|
12
|
+
* A `D1Like` over `node:sqlite`, with the schema already applied.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately not its own implementation. The wrapper is
|
|
15
|
+
* {@link wrapSqlite}, the one an operator's service runs, so a test that passes
|
|
16
|
+
* here is a test of the shipped adapter rather than of a second one written to
|
|
17
|
+
* agree with it. What differs is only the lifecycle: this applies `SCHEMA`
|
|
18
|
+
* outright, where `openDatabase` has to tell a fresh file from a deployed one.
|
|
19
|
+
*
|
|
20
|
+
* A path may be passed for a test that wants a file it can reopen; `':memory:'`
|
|
21
|
+
* is the default and disappears with the handle.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createSqliteD1(path?: string): Promise<SqliteD1>;
|
|
24
|
+
export interface MemoryR2 extends R2Like {
|
|
25
|
+
/** Every key currently held, sorted. The assertion surface for retention tests. */
|
|
26
|
+
keys(): readonly string[];
|
|
27
|
+
/** Base64 of one object, for a test that wants to compare bytes rather than count them. */
|
|
28
|
+
read(key: string): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Make the next `count` calls of any kind throw.
|
|
31
|
+
*
|
|
32
|
+
* Because "a store failure is never a verdict" is the rule this whole package
|
|
33
|
+
* is arranged around, and a rule with no test that breaks it is a comment.
|
|
34
|
+
*/
|
|
35
|
+
fail(message: string, count?: number): void;
|
|
36
|
+
}
|
|
37
|
+
export declare function createMemoryR2(): MemoryR2;
|
|
38
|
+
//# sourceMappingURL=testing.d.ts.map
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { base64Of } from './bindings.js';
|
|
3
|
+
import { wrapSqlite } from './node/database.js';
|
|
4
|
+
import { applySchema } from './schema.js';
|
|
5
|
+
/**
|
|
6
|
+
* Cloudflare, doubled — and doubled at the level where the doubling is honest.
|
|
7
|
+
*
|
|
8
|
+
* A fake that answered queries from a `Map` would test this package's control
|
|
9
|
+
* flow and nothing else: the schema, the indexes, the append-only triggers, the
|
|
10
|
+
* `ON CONFLICT` clause and every `ORDER BY` would be strings nobody executed.
|
|
11
|
+
* D1 *is* SQLite, and `node:sqlite` is already a dependency of
|
|
12
|
+
* [`@variance-authority/server`](../../server), so the double below runs the real
|
|
13
|
+
* statements against a real engine. What the tests then verify is the SQL, not a
|
|
14
|
+
* paraphrase of it.
|
|
15
|
+
*
|
|
16
|
+
* **What it does not verify** is Cloudflare. Request limits, object-size
|
|
17
|
+
* ceilings, quotas, consistency between two Workers writing at once, and whether
|
|
18
|
+
* D1's `batch` is transactional in the way this package needs — none of those are
|
|
19
|
+
* measured, here or anywhere else in this repository. They are stated as unmet in
|
|
20
|
+
* [ADR-0023](../../../docs/context/adr/0023-a-service-is-named-for-what-it-is.md) and the checkpoint rather than
|
|
21
|
+
* implied to be fine by a green suite.
|
|
22
|
+
*
|
|
23
|
+
* Exported as `@variance-authority/tribunal/testing` because it is also the
|
|
24
|
+
* thing an operator wants: a way to run their own wiring — their routes, their
|
|
25
|
+
* ingest, their retention settings — in a plain `vitest` process, with no
|
|
26
|
+
* `wrangler`, no container, and no account.
|
|
27
|
+
*/
|
|
28
|
+
const { DatabaseSync: Database } = createRequire(import.meta.url)('node:sqlite');
|
|
29
|
+
/**
|
|
30
|
+
* A `D1Like` over `node:sqlite`, with the schema already applied.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately not its own implementation. The wrapper is
|
|
33
|
+
* {@link wrapSqlite}, the one an operator's service runs, so a test that passes
|
|
34
|
+
* here is a test of the shipped adapter rather than of a second one written to
|
|
35
|
+
* agree with it. What differs is only the lifecycle: this applies `SCHEMA`
|
|
36
|
+
* outright, where `openDatabase` has to tell a fresh file from a deployed one.
|
|
37
|
+
*
|
|
38
|
+
* A path may be passed for a test that wants a file it can reopen; `':memory:'`
|
|
39
|
+
* is the default and disappears with the handle.
|
|
40
|
+
*/
|
|
41
|
+
export async function createSqliteD1(path = ':memory:') {
|
|
42
|
+
const database = new Database(path);
|
|
43
|
+
const db = wrapSqlite(database);
|
|
44
|
+
await applySchema(db);
|
|
45
|
+
return db;
|
|
46
|
+
}
|
|
47
|
+
export function createMemoryR2() {
|
|
48
|
+
const objects = new Map();
|
|
49
|
+
let failures = 0;
|
|
50
|
+
let reason = '';
|
|
51
|
+
const check = () => {
|
|
52
|
+
if (failures <= 0)
|
|
53
|
+
return;
|
|
54
|
+
failures -= 1;
|
|
55
|
+
throw new Error(reason);
|
|
56
|
+
};
|
|
57
|
+
const object = (buffer) => ({
|
|
58
|
+
arrayBuffer: async () => buffer,
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
async get(key) {
|
|
62
|
+
check();
|
|
63
|
+
const stored = objects.get(key);
|
|
64
|
+
return stored === undefined ? null : object(stored);
|
|
65
|
+
},
|
|
66
|
+
async head(key) {
|
|
67
|
+
check();
|
|
68
|
+
return objects.has(key) ? { key } : null;
|
|
69
|
+
},
|
|
70
|
+
async put(key, value) {
|
|
71
|
+
check();
|
|
72
|
+
objects.set(key, value);
|
|
73
|
+
return { key };
|
|
74
|
+
},
|
|
75
|
+
async delete(keys) {
|
|
76
|
+
check();
|
|
77
|
+
for (const key of typeof keys === 'string' ? [keys] : keys)
|
|
78
|
+
objects.delete(key);
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
keys: () => [...objects.keys()].sort(),
|
|
82
|
+
read: (key) => {
|
|
83
|
+
const stored = objects.get(key);
|
|
84
|
+
return stored === undefined ? undefined : base64Of(stored);
|
|
85
|
+
},
|
|
86
|
+
fail: (message, count = 1) => {
|
|
87
|
+
reason = message;
|
|
88
|
+
failures = count;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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","sourcesContent":["import { createRequire } from 'node:module';\nimport type { DatabaseSync } from 'node:sqlite';\nimport { base64Of, type R2Like, type R2ObjectLike } from './bindings.js';\nimport { wrapSqlite, type SqliteDatabase } from './node/database.js';\nimport { applySchema } from './schema.js';\n\n/**\n * Cloudflare, doubled — and doubled at the level where the doubling is honest.\n *\n * A fake that answered queries from a `Map` would test this package's control\n * flow and nothing else: the schema, the indexes, the append-only triggers, the\n * `ON CONFLICT` clause and every `ORDER BY` would be strings nobody executed.\n * D1 *is* SQLite, and `node:sqlite` is already a dependency of\n * [`@variance-authority/server`](../../server), so the double below runs the real\n * statements against a real engine. What the tests then verify is the SQL, not a\n * paraphrase of it.\n *\n * **What it does not verify** is Cloudflare. Request limits, object-size\n * ceilings, quotas, consistency between two Workers writing at once, and whether\n * D1's `batch` is transactional in the way this package needs — none of those are\n * measured, here or anywhere else in this repository. They are stated as unmet in\n * [ADR-0023](../../../docs/context/adr/0023-a-service-is-named-for-what-it-is.md) and the checkpoint rather than\n * implied to be fine by a green suite.\n *\n * Exported as `@variance-authority/tribunal/testing` because it is also the\n * thing an operator wants: a way to run their own wiring — their routes, their\n * ingest, their retention settings — in a plain `vitest` process, with no\n * `wrangler`, no container, and no account.\n */\n\nconst { DatabaseSync: Database } = createRequire(import.meta.url)('node:sqlite') as {\n DatabaseSync: new (path: string) => DatabaseSync;\n};\n\n/**\n * A database with the schema already applied, and a way to let go of it.\n *\n * The same shape [`node/database.ts`](./node/database.ts) hands a running\n * service — this is that adapter, over `':memory:'`, with the schema applied\n * unconditionally because a fresh in-memory file is never anything else.\n */\nexport type SqliteD1 = SqliteDatabase;\n\n/**\n * A `D1Like` over `node:sqlite`, with the schema already applied.\n *\n * Deliberately not its own implementation. The wrapper is\n * {@link wrapSqlite}, the one an operator's service runs, so a test that passes\n * here is a test of the shipped adapter rather than of a second one written to\n * agree with it. What differs is only the lifecycle: this applies `SCHEMA`\n * outright, where `openDatabase` has to tell a fresh file from a deployed one.\n *\n * A path may be passed for a test that wants a file it can reopen; `':memory:'`\n * is the default and disappears with the handle.\n */\nexport async function createSqliteD1(path = ':memory:'): Promise<SqliteD1> {\n const database = new Database(path);\n const db = wrapSqlite(database);\n await applySchema(db);\n return db;\n}\n\nexport interface MemoryR2 extends R2Like {\n /** Every key currently held, sorted. The assertion surface for retention tests. */\n keys(): readonly string[];\n /** Base64 of one object, for a test that wants to compare bytes rather than count them. */\n read(key: string): string | undefined;\n /**\n * Make the next `count` calls of any kind throw.\n *\n * Because \"a store failure is never a verdict\" is the rule this whole package\n * is arranged around, and a rule with no test that breaks it is a comment.\n */\n fail(message: string, count?: number): void;\n}\n\nexport function createMemoryR2(): MemoryR2 {\n const objects = new Map<string, ArrayBuffer>();\n let failures = 0;\n let reason = '';\n\n const check = (): void => {\n if (failures <= 0) return;\n failures -= 1;\n throw new Error(reason);\n };\n\n const object = (buffer: ArrayBuffer): R2ObjectLike => ({\n arrayBuffer: async (): Promise<ArrayBuffer> => buffer,\n });\n\n return {\n async get(key: string): Promise<R2ObjectLike | null> {\n check();\n const stored = objects.get(key);\n return stored === undefined ? null : object(stored);\n },\n async head(key: string): Promise<unknown | null> {\n check();\n return objects.has(key) ? { key } : null;\n },\n async put(key: string, value: ArrayBuffer): Promise<unknown> {\n check();\n objects.set(key, value);\n return { key };\n },\n async delete(keys: string | readonly string[]): Promise<unknown> {\n check();\n for (const key of typeof keys === 'string' ? [keys] : keys) objects.delete(key);\n return undefined;\n },\n\n keys: () => [...objects.keys()].sort(),\n read: (key: string) => {\n const stored = objects.get(key);\n return stored === undefined ? undefined : base64Of(stored);\n },\n fail: (message: string, count = 1): void => {\n reason = message;\n failures = count;\n },\n };\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"__probe.js","sourceRoot":"","sources":["../../src/ui/__probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAG5D,MAAM,UAAU,KAAK,CAAC,KAAkB;IACtC,OAAO,cAAc,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;AACvD,CAAC","sourcesContent":["import { clusterChanges } from '@variance-authority/report';\nimport type { BuildDetail } from '../review.js';\n\nexport function probe(build: BuildDetail): number {\n return clusterChanges(build.subjects).changes.length;\n}\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the run decided about each thing that moved, addressed by the pair.
|
|
3
|
+
*
|
|
4
|
+
* The build page used to answer *why did this move* by walking the census
|
|
5
|
+
* itself — up from the component that moved, through what encloses it, to a name
|
|
6
|
+
* the diff reaches. That walk was a reconstruction, and it worked from the
|
|
7
|
+
* thinner half of the record: the census carries edges, and the run that wrote it
|
|
8
|
+
* also had the diff, the source index that maps a component to the file declaring
|
|
9
|
+
* it, the props digest each rendering was grouped under, and the subjects where
|
|
10
|
+
* the same component with the same props held still. It reached a conclusion from
|
|
11
|
+
* all four and wrote one sentence per movement. None of it crossed the wire.
|
|
12
|
+
*
|
|
13
|
+
* So the page asks rather than re-derives. What is here is the addressing — a
|
|
14
|
+
* movement is about a *pair*, and the two questions a surface asks of it are
|
|
15
|
+
* *what happened to this component here* and *what happened to it anywhere* —
|
|
16
|
+
* plus the one fold that turns a component's several answers into the band it is
|
|
17
|
+
* read under.
|
|
18
|
+
*
|
|
19
|
+
* ## The pair is not a formality
|
|
20
|
+
*
|
|
21
|
+
* The same `Button` is `edited` on the page whose file the diff names and
|
|
22
|
+
* `upstream` on the page where a changed parent hands it a different label. A
|
|
23
|
+
* surface keyed by component alone shows one of those and is wrong about the
|
|
24
|
+
* other, and which one it is wrong about depends on iteration order.
|
|
25
|
+
*/
|
|
26
|
+
import type { BuildDetail, MovementView } from '../review-types.js';
|
|
27
|
+
/** The five rungs, as the report's ladder names them. */
|
|
28
|
+
export type Rung = MovementView['cause'];
|
|
29
|
+
/** One build's attributions, indexed the two ways a surface asks for them. */
|
|
30
|
+
export interface Attributions {
|
|
31
|
+
/** What the run concluded about this component in this render. */
|
|
32
|
+
readonly at: (component: string, subject: string) => MovementView | undefined;
|
|
33
|
+
/** Every conclusion about this component, in the order the store returned. */
|
|
34
|
+
readonly about: (component: string) => readonly MovementView[];
|
|
35
|
+
}
|
|
36
|
+
export declare function attributionsOf(build: BuildDetail): Attributions;
|
|
37
|
+
/**
|
|
38
|
+
* One component's rung, over every render it moved in.
|
|
39
|
+
*
|
|
40
|
+
* `undefined` when the run recorded nothing about it — which is not a rung and
|
|
41
|
+
* must never be drawn as one. A build ingested before attributions were carried
|
|
42
|
+
* returns this for everything, and a reader that turned it into *unexplained*
|
|
43
|
+
* would be reporting a missing input as a finding.
|
|
44
|
+
*/
|
|
45
|
+
export declare function rungAcross(movements: readonly MovementView[]): Rung | undefined;
|
|
46
|
+
//# sourceMappingURL=attribution.d.ts.map
|