@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @variance-authority/tribunal
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c54a8d1: Serve every history path, and let a reviewer ask what the record already knows.
|
|
8
|
+
|
|
9
|
+
Three of the eight protocol paths were unserved and two fields were dropped on
|
|
10
|
+
the way in, and none of it presented as an error. Without `/v1/current` a run
|
|
11
|
+
compares against nothing, so every component reads as new and drift is never
|
|
12
|
+
detected. Without `/v1/approvals` every observation stays unapproved, so churn
|
|
13
|
+
answers *this component has never changed* about a component that changed forty
|
|
14
|
+
times. Without `instabilities` and `run.swept` a flake has no denominator. All
|
|
15
|
+
three failures sound like a stable suite.
|
|
16
|
+
|
|
17
|
+
The derived reads — churn, reach, flakiness, the value journey, the last change —
|
|
18
|
+
now answer the review capability as well as the ingest one. They write nothing,
|
|
19
|
+
and the browser drawing a review page holds the review token; refusing them there
|
|
20
|
+
meant a surface that can approve a change it cannot put in context. The writes
|
|
21
|
+
and `/v1/current` stay the ingest token's.
|
|
22
|
+
|
|
23
|
+
On the page: a **Changelog** tab grouping every approval by the shape that was
|
|
24
|
+
approved, counting the approvals nothing could attribute rather than dropping
|
|
25
|
+
them, and a per-subject **record** panel — how often this subject read
|
|
26
|
+
differently, whether it has since, how often its cause caused an approved change,
|
|
27
|
+
and how far that component reaches. Fetched when the reviewer asks, because a
|
|
28
|
+
build with three hundred changed subjects would otherwise make nine hundred
|
|
29
|
+
requests to draw a page on which one is read. A flake rate stays absent until
|
|
30
|
+
something swept: `0%` is the confident answer to a question nobody asked.
|
|
31
|
+
- 89bd179: Export `MIGRATIONS` and `INITIAL_VERSION` from the package entrypoint.
|
|
32
|
+
|
|
33
|
+
The README sends an operator whose database is already deployed to `MIGRATIONS`
|
|
34
|
+
— "what an existing database needs is the part it is missing" — and the constant
|
|
35
|
+
lived in a private module. The entrypoint offered `SCHEMA`, which fails on the
|
|
36
|
+
first `CREATE TABLE` against that database by design, and nothing else. There
|
|
37
|
+
was no way to reach the steps from outside the package.
|
|
38
|
+
|
|
39
|
+
`INITIAL_VERSION` goes with it, because `MIGRATIONS` is indexed against it: step
|
|
40
|
+
`i` lands on `INITIAL_VERSION + i + 1`, so a database reporting `schema_version`
|
|
41
|
+
`n` needs every step from `n - INITIAL_VERSION` on.
|
|
42
|
+
- f2564ed: Name the sweep count for what happened to it
|
|
43
|
+
|
|
44
|
+
`SweepReport.decisions` became `decisionsKept`. Every other number in that
|
|
45
|
+
report is a removal, and the README sentence beside it says the store "reports
|
|
46
|
+
counts for everything it removed" — so an operator reading `decisions: 4`
|
|
47
|
+
concludes four approvals were deleted, which is the one thing the `decisions`
|
|
48
|
+
table's permanence trigger exists to make impossible. The number was always the
|
|
49
|
+
opposite: approvals that outlived the builds this call removed.
|
|
50
|
+
- 896d64a: Run the review service on a machine you own — a SQLite file, a directory, a port.
|
|
51
|
+
|
|
52
|
+
The package shipped one deployment and named none: every module above the
|
|
53
|
+
bindings takes a `D1Like` and an `R2Like`, so what stood between it and a laptop
|
|
54
|
+
was two adapters and a shell. `@variance-authority/tribunal/node` supplies them.
|
|
55
|
+
`openDatabase` opens, creates and migrates a SQLite file through `node:sqlite`
|
|
56
|
+
and reports the version it settled on; `createDirectoryBucket` puts objects in a
|
|
57
|
+
directory through `node:fs`, each written to a staging file and renamed so a
|
|
58
|
+
reader never sees half of one, and refusing a key a case-folding volume would
|
|
59
|
+
land on another key's file. `serveTribunal` binds a `node:http` server over the
|
|
60
|
+
same `createTribunalRoutes` the Next.js adapter uses, so the token-attaching rule
|
|
61
|
+
has one implementation rather than two.
|
|
62
|
+
|
|
63
|
+
`variance-authority-tribunal` is that service as an executable, configured by the
|
|
64
|
+
environment. It refuses an unnamed project, refuses a non-loopback bind unless
|
|
65
|
+
`VARIANCE_TRIBUNAL_TRUST_NETWORK` says so, and on a network bind serves no review
|
|
66
|
+
surface at all — there would be nothing between an approve button and the
|
|
67
|
+
internet. On loopback it serves the surface and treats an untokened caller as the
|
|
68
|
+
reviewer, because anything that can open the port is already the person who
|
|
69
|
+
started the process. No token is written into the page or into the startup line.
|
|
70
|
+
|
|
71
|
+
The SQLite adapter `testing.ts` had privately is now that shipped adapter, so
|
|
72
|
+
what the suite exercises is what an operator runs.
|
|
73
|
+
|
|
74
|
+
### Patch Changes
|
|
75
|
+
|
|
76
|
+
- 44a174f: Name the binding that never arrived
|
|
77
|
+
|
|
78
|
+
`createBucketStore` and `createD1Backend` now refuse a `db` or `bucket` that is
|
|
79
|
+
not the binding it claims to be. A `wrangler.jsonc` declaring the database as
|
|
80
|
+
`D1` while the entry reads `env.DB` used to reach the first statement that
|
|
81
|
+
touched a row and surface as `Cannot read properties of undefined (reading
|
|
82
|
+
'prepare')` — a platform-shaped error for a configuration line — and a bucket
|
|
83
|
+
declared for production and not for a preview environment would have written
|
|
84
|
+
sidecar rows without the images they describe.
|
|
85
|
+
- 08ab85c: Refuse a deployment that never said which project it is.
|
|
86
|
+
|
|
87
|
+
`project` is required, scopes every row and every object key, and had no runtime
|
|
88
|
+
check. `undefined` reached D1 as a bind parameter and came back as *the baseline
|
|
89
|
+
store could not reach its database or its bucket* — the platform blamed for a
|
|
90
|
+
line in a wrangler file — and a blank string quietly became a namespace nobody
|
|
91
|
+
named. `createBucketStore` now refuses both with a sentence, which `createTribunal`
|
|
92
|
+
and `createReviewStore` inherit, the way the two token rules already answer.
|
|
93
|
+
- Updated dependencies [1d402d1]
|
|
94
|
+
- Updated dependencies [e8fee66]
|
|
95
|
+
- Updated dependencies [5c34e6d]
|
|
96
|
+
- @variance-authority/report@0.1.0
|
|
97
|
+
- @variance-authority/core@0.1.0
|
|
98
|
+
- @variance-authority/history@0.1.0
|
|
99
|
+
- @variance-authority/raster@0.1.0
|
|
100
|
+
- @variance-authority/server@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,506 @@
|
|
|
1
|
+
<p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
|
|
2
|
+
|
|
3
|
+
# @variance-authority/tribunal
|
|
4
|
+
|
|
5
|
+
> A self-hosted review service for Variance Authority baselines, history and per-subject decisions.
|
|
6
|
+
|
|
7
|
+
Use this package when you are deploying a self-hosted review service for
|
|
8
|
+
baselines, history, and per-subject decisions. It supplies the Worker handler,
|
|
9
|
+
storage adapters, review API, and optional React UI; it does not provide a hosted
|
|
10
|
+
endpoint or an integration that posts runs for you.
|
|
11
|
+
|
|
12
|
+
The deployment owns the database and the object store, the runtime has to serve
|
|
13
|
+
`fetch`, and it takes two different bearer tokens of at least 16 characters.
|
|
14
|
+
|
|
15
|
+
**Two deployments ship, and they run the same router.** On Cloudflare the
|
|
16
|
+
database is D1, the object store is R2, and the runtime is a Worker. On a machine
|
|
17
|
+
you own — a laptop, an EC2 instance, a container, the package does not ask — the
|
|
18
|
+
database is a SQLite file through `node:sqlite` and the object store is a
|
|
19
|
+
directory through `node:fs`. Every module above the bindings takes a `D1Like` and
|
|
20
|
+
an `R2Like` and names no runtime, so the routes, the refusals and the status
|
|
21
|
+
codes are the same code in both. The name says what it is rather than where it
|
|
22
|
+
runs.
|
|
23
|
+
|
|
24
|
+
Anything else that satisfies those two interfaces is an adapter away, but the
|
|
25
|
+
package makes no portability promise beyond the two it ships; the operator owns
|
|
26
|
+
their deployment's platform limits.
|
|
27
|
+
|
|
28
|
+
Producing runs and reports is the job of `@variance-authority/cli`. This package
|
|
29
|
+
stores the evidence they send and gives a reviewer a place to inspect and settle
|
|
30
|
+
it; it does not render subjects or decide what a run should contain.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @variance-authority/tribunal
|
|
34
|
+
```
|
|
35
|
+
## The service surfaces
|
|
36
|
+
|
|
37
|
+
| surface | contract | defined by |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| baselines | `RasterStore` behind `/baseline/*` and `/cache/*` | `raster`, `remote` |
|
|
40
|
+
| history | `HistoryBackend` behind `/v1/*` — every path the protocol defines | `history`, `server` |
|
|
41
|
+
| review | builds, subjects, decisions | here |
|
|
42
|
+
|
|
43
|
+
`variance run` reaches this deployment with **no change to the CLI** —
|
|
44
|
+
`baselines.kind: "remote"` and a URL — and a client built from
|
|
45
|
+
`@variance-authority/history/client` reaches it with no change to the client.
|
|
46
|
+
|
|
47
|
+
A **subject** is one rendered unit under test — a story, route, or fixture —
|
|
48
|
+
identified by a string id (for example `story:card`). `@variance-authority/cli`
|
|
49
|
+
decides each subject's verdict (`unchanged`, `changed`, `new`, among others)
|
|
50
|
+
upstream; this service stores the evidence and the decision made about it.
|
|
51
|
+
|
|
52
|
+
## Entrypoints
|
|
53
|
+
|
|
54
|
+
| entrypoint | requires | holds |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `@variance-authority/tribunal` | nothing | the binding types, `SCHEMA`, `applySchema`, `MIGRATIONS` |
|
|
57
|
+
| `@variance-authority/tribunal/store` | D1 and R2 | `createBucketStore` — baselines |
|
|
58
|
+
| `@variance-authority/tribunal/history` | D1 | `createD1Backend` — the drift record |
|
|
59
|
+
| `@variance-authority/tribunal/review` | D1 and R2 | `createReviewStore` — builds, decisions, retention |
|
|
60
|
+
| `@variance-authority/tribunal/worker` | D1, R2, two tokens | `createTribunal` — one `fetch` handler |
|
|
61
|
+
| `@variance-authority/tribunal/worker-entry` | the bindings, as an `env` | the deployable module: `export default { fetch }`, and `wrangler.jsonc` beside it |
|
|
62
|
+
| `@variance-authority/tribunal/node` | Node 22, a writable file and directory | `openDatabase`, `createDirectoryBucket`, `serveTribunal` — the same service over `node:sqlite`, `node:fs` and `node:http`, plus the `variance-authority-tribunal` executable |
|
|
63
|
+
| `@variance-authority/tribunal/ui` | React | the review surface, its JSON client, its stylesheet |
|
|
64
|
+
| `@variance-authority/tribunal/next` | an App Router app | `createTribunalRoutes` — route handlers for a Next.js App Router |
|
|
65
|
+
| `@variance-authority/tribunal/testing` | Node 22 | D1 over `node:sqlite`, an in-memory bucket |
|
|
66
|
+
|
|
67
|
+
**The table is for reading, not for slimming an install.** This is one service,
|
|
68
|
+
deployed once, so React is an ordinary production dependency even though only
|
|
69
|
+
`/ui` and `/next` touch it. The rows above say which entrypoint needs what,
|
|
70
|
+
which is worth knowing when you read the code or split the deployment
|
|
71
|
+
across two Workers, not because the package is trying to keep an install small.
|
|
72
|
+
|
|
73
|
+
## Wiring it up
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// worker.ts — yours, not ours
|
|
77
|
+
import type { D1Like, R2Like } from '@variance-authority/tribunal';
|
|
78
|
+
import { createTribunal } from '@variance-authority/tribunal/worker';
|
|
79
|
+
|
|
80
|
+
interface Env {
|
|
81
|
+
readonly DB: D1Like;
|
|
82
|
+
readonly BUCKET: R2Like;
|
|
83
|
+
readonly VARIANCE_INGEST_TOKEN: string;
|
|
84
|
+
readonly VARIANCE_REVIEW_TOKEN: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default {
|
|
88
|
+
fetch(request: Request, env: Env): Promise<Response> {
|
|
89
|
+
return createTribunal({
|
|
90
|
+
db: env.DB,
|
|
91
|
+
bucket: env.BUCKET,
|
|
92
|
+
project: 'todomvc',
|
|
93
|
+
ingestToken: env.VARIANCE_INGEST_TOKEN,
|
|
94
|
+
reviewToken: env.VARIANCE_REVIEW_TOKEN,
|
|
95
|
+
}).fetch(request);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`createTribunal` takes:
|
|
101
|
+
|
|
102
|
+
| option | default | what it decides |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `db` | required | the D1 binding |
|
|
105
|
+
| `bucket` | required | the R2 binding |
|
|
106
|
+
| `project` | required | scopes every row and every object key, so one deployment serves several repositories without their `story:card` colliding. There is no default: an invented one puts two projects' baselines in one namespace and the first symptom is a mass `changed` |
|
|
107
|
+
| `ingestToken` | required | written into CI. Writes builds, baselines and history. 16 characters or more |
|
|
108
|
+
| `reviewToken` | required | held by people. Reads the review surface and decides. 16 characters or more, and not the same string as `ingestToken` |
|
|
109
|
+
| `retentionDays` | `30` | days of builds `POST /review/sweep` keeps. Applied on request rather than on a timer, because a Worker has no timer and this package will not invent a cron the operator did not ask for — wire it to a scheduled trigger, call it from a CI job, or never |
|
|
110
|
+
| `now` | the wall clock | supplies every recorded `at`; override it when the deployment has its own clock source |
|
|
111
|
+
|
|
112
|
+
`env.DB` and `env.BUCKET` are Cloudflare's own `D1Database` and `R2Bucket`,
|
|
113
|
+
accepted as-is via structural typing: this package declares only the subset it
|
|
114
|
+
uses, and does **not** depend on `@cloudflare/workers-types` — which is why the
|
|
115
|
+
store is testable in a plain `vitest` process.
|
|
116
|
+
|
|
117
|
+
Apply the schema yourself, once — `applySchema(db)`, or the statements in
|
|
118
|
+
`SCHEMA` through whatever you use for migrations. Nothing here migrates on a
|
|
119
|
+
request, since D1 has no advisory lock to serialize a migration running under
|
|
120
|
+
concurrent load.
|
|
121
|
+
|
|
122
|
+
An **already-deployed** database is not upgraded by that call: `SCHEMA` is the
|
|
123
|
+
whole shape, and applying it a second time fails on the first `CREATE TABLE`.
|
|
124
|
+
Apply `MIGRATIONS` instead — one entry per version after the package's initial
|
|
125
|
+
shape, each writing the version it lands on. Step `i` lands on
|
|
126
|
+
`INITIAL_VERSION + i + 1`, so a database reporting `schema_version` `n` needs
|
|
127
|
+
every step from `n - INITIAL_VERSION` on.
|
|
128
|
+
|
|
129
|
+
### Or deploy the one that ships
|
|
130
|
+
|
|
131
|
+
The module above is the shape to copy when you want your own. If you do not,
|
|
132
|
+
`worker-entry` **is** that module, and `wrangler.jsonc` beside it declares the
|
|
133
|
+
Worker, the D1 database and the R2 bucket:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
wrangler d1 create variance-tribunal
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Put the id it prints into `wrangler.jsonc`, then:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
wrangler r2 bucket create variance-tribunal
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
wrangler d1 migrations apply variance-tribunal --remote
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
wrangler secret put INGEST_TOKEN
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
wrangler secret put REVIEW_TOKEN
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
wrangler deploy
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`worker-entry` is the only file in the package that reads an environment, and it
|
|
162
|
+
reads four names plus two optional ones:
|
|
163
|
+
|
|
164
|
+
| name | kind | what it decides |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `DB` | binding | D1. The schema is in `migrations/`. A name that does not match what `d1_databases` declares is refused by name |
|
|
167
|
+
| `BUCKET` | binding | R2. Baseline and candidate bytes; never a row. Declared for production and not for a preview environment is refused by name |
|
|
168
|
+
| `INGEST_TOKEN` | secret | written into CI. Writes builds, baselines and history. 16 characters or more |
|
|
169
|
+
| `REVIEW_TOKEN` | secret | held by people. Reads the review surface and decides. 16 characters or more |
|
|
170
|
+
| `PROJECT` | var, default `default` | scopes every row and object |
|
|
171
|
+
| `RETENTION_DAYS` | var, default `30` | days of builds `POST /review/sweep` keeps. A value that is not a positive finite number falls back rather than sweeping everything |
|
|
172
|
+
|
|
173
|
+
A bad environment answers **500 with a sentence**, not a deployment-wide platform
|
|
174
|
+
error: construction happens inside `fetch`, so *your token is too short* and *your
|
|
175
|
+
two tokens are the same* reach the operator as the response body.
|
|
176
|
+
|
|
177
|
+
`PROJECT` is a deployment setting, so one deployment serves one project; a
|
|
178
|
+
second tenant needs a second deployment.
|
|
179
|
+
|
|
180
|
+
The migrations in `migrations/` are **generated** from `SCHEMA` by
|
|
181
|
+
`tools/tribunal-migrations.mjs` — change `schema.ts` and rebuild rather than
|
|
182
|
+
editing a generated `.sql` file. `wrangler.jsonc` carries the project name and
|
|
183
|
+
no credentials; keep both tokens in Wrangler secrets.
|
|
184
|
+
|
|
185
|
+
### Or run it on a machine you own
|
|
186
|
+
|
|
187
|
+
No account, no `wrangler`, no platform. `variance-authority-tribunal` is the same
|
|
188
|
+
service over a SQLite file and a directory, and it is configured entirely by the
|
|
189
|
+
environment so that nothing about a deployment lives in a shell history:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
VARIANCE_TRIBUNAL_PROJECT=todomvc VARIANCE_TRIBUNAL_INGEST_TOKEN=$INGEST VARIANCE_TRIBUNAL_REVIEW_TOKEN=$REVIEW npx variance-authority-tribunal
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
| variable | default | what it decides |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| `VARIANCE_TRIBUNAL_PROJECT` | required | scopes every row and object key. No default, for the reason `project` has none above |
|
|
198
|
+
| `VARIANCE_TRIBUNAL_INGEST_TOKEN` | required | written into CI. Writes builds, baselines and history. 16 characters or more |
|
|
199
|
+
| `VARIANCE_TRIBUNAL_REVIEW_TOKEN` | required | held by people. Reads the review surface and decides. 16 characters or more, and not the ingest token |
|
|
200
|
+
| `VARIANCE_TRIBUNAL_PORT` | `7789` | a whole number, or the process refuses to start |
|
|
201
|
+
| `VARIANCE_TRIBUNAL_HOST` | `127.0.0.1` | the bind address. Anything reachable from off the machine also needs `VARIANCE_TRIBUNAL_TRUST_NETWORK` |
|
|
202
|
+
| `VARIANCE_TRIBUNAL_DB` | `variance-tribunal.db` | the SQLite file. Created and migrated on start; the startup line prints its absolute path and its schema version |
|
|
203
|
+
| `VARIANCE_TRIBUNAL_STORAGE` | `variance-tribunal-objects` | the directory holding baseline and candidate bytes |
|
|
204
|
+
| `VARIANCE_TRIBUNAL_RETENTION_DAYS` | the package default | days of builds `POST /review/sweep` keeps |
|
|
205
|
+
| `VARIANCE_TRIBUNAL_REVIEWER` | the OS user | the name written on decisions made through the served UI |
|
|
206
|
+
| `VARIANCE_TRIBUNAL_TRUST_NETWORK` | unset | confirms a non-loopback bind, and see what it costs below |
|
|
207
|
+
|
|
208
|
+
**On loopback, a browser is a reviewer.** The bare URL serves the review surface,
|
|
209
|
+
and a caller with no token is treated as holding the review token — because
|
|
210
|
+
anything that can open `127.0.0.1:7789` is already running as the person who
|
|
211
|
+
started it, and asking them to paste their own secret back to themselves buys
|
|
212
|
+
nothing. **On a network bind the review surface is not served at all**, and no
|
|
213
|
+
call is authorized without a token: there would be nothing between an approve
|
|
214
|
+
button and the internet. Put the Next.js adapter behind your own sign-in for
|
|
215
|
+
that, or leave the process on loopback behind a proxy that authenticates.
|
|
216
|
+
|
|
217
|
+
**The tokens are never printed and never rendered.** The startup line names the
|
|
218
|
+
variables, not their values, and the served page carries the endpoint and the
|
|
219
|
+
reviewer name and nothing else — the browser calls the service, which holds the
|
|
220
|
+
token, exactly as it does behind Next.js.
|
|
221
|
+
|
|
222
|
+
The schema is applied and migrated on start. That is the one thing the Node shell
|
|
223
|
+
does that the Worker will not: a file has an exclusive lock and a single writer,
|
|
224
|
+
so there is no concurrent-migration race to lose.
|
|
225
|
+
|
|
226
|
+
Composing it yourself is the same three pieces without the executable, which is
|
|
227
|
+
what you want when the process is also serving something else:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { createDirectoryBucket, openDatabase, serveTribunal } from '@variance-authority/tribunal/node';
|
|
231
|
+
import { createTribunal } from '@variance-authority/tribunal/worker';
|
|
232
|
+
|
|
233
|
+
const ingest = process.env.INGEST_TOKEN ?? '';
|
|
234
|
+
const review = process.env.REVIEW_TOKEN ?? '';
|
|
235
|
+
|
|
236
|
+
const service = await serveTribunal({
|
|
237
|
+
tribunal: createTribunal({
|
|
238
|
+
db: await openDatabase('variance-tribunal.db'),
|
|
239
|
+
bucket: createDirectoryBucket('variance-tribunal-objects'),
|
|
240
|
+
project: 'todomvc',
|
|
241
|
+
ingestToken: ingest,
|
|
242
|
+
reviewToken: review,
|
|
243
|
+
}),
|
|
244
|
+
host: '127.0.0.1',
|
|
245
|
+
port: 7789,
|
|
246
|
+
authorize: (request) =>
|
|
247
|
+
request.headers.get('authorization') === `Bearer ${ingest}` ? 'ingest' : 'review',
|
|
248
|
+
tokens: { ingest, review },
|
|
249
|
+
ui: true,
|
|
250
|
+
reviewer: 'marina',
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
service.url; // http://127.0.0.1:7789
|
|
254
|
+
await service.close();
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`serveTribunal` takes the `tribunal` to serve, an `authorize` and `tokens` that
|
|
258
|
+
mean exactly what they mean for `createTribunalRoutes` — it is the same function
|
|
259
|
+
underneath — plus `host`, `port`, and two that only a served page needs: `ui`,
|
|
260
|
+
which decides whether the review surface and its bundle are served at all, and
|
|
261
|
+
`reviewer`, the name written on decisions made through it. `openDatabase` opens,
|
|
262
|
+
creates or migrates the file and reports the `version` it settled on;
|
|
263
|
+
`createDirectoryBucket` writes each object through a staging file and renames it,
|
|
264
|
+
so a reader never sees half of one.
|
|
265
|
+
|
|
266
|
+
### The review surface
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
// app/variance/page.tsx
|
|
270
|
+
import { ReviewApp, REVIEW_STYLES, createReviewClient } from '@variance-authority/tribunal/ui';
|
|
271
|
+
|
|
272
|
+
declare function whoIsThis(): Promise<string>;
|
|
273
|
+
|
|
274
|
+
const client = createReviewClient({ endpoint: '/variance' });
|
|
275
|
+
|
|
276
|
+
export default async function VariancePage(): Promise<React.ReactElement> {
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
<style>{REVIEW_STYLES}</style>
|
|
280
|
+
<ReviewApp client={client} reviewer={await whoIsThis()} />
|
|
281
|
+
</>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// app/variance/[[...path]]/route.ts
|
|
288
|
+
import type { D1Like, R2Like } from '@variance-authority/tribunal';
|
|
289
|
+
import { createTribunalRoutes } from '@variance-authority/tribunal/next';
|
|
290
|
+
import { createTribunal } from '@variance-authority/tribunal/worker';
|
|
291
|
+
|
|
292
|
+
declare const env: {
|
|
293
|
+
readonly DB: D1Like;
|
|
294
|
+
readonly BUCKET: R2Like;
|
|
295
|
+
readonly VARIANCE_INGEST_TOKEN: string;
|
|
296
|
+
readonly VARIANCE_REVIEW_TOKEN: string;
|
|
297
|
+
};
|
|
298
|
+
declare function isSignedIn(request: Request): Promise<boolean>;
|
|
299
|
+
|
|
300
|
+
const worker = createTribunal({
|
|
301
|
+
db: env.DB,
|
|
302
|
+
bucket: env.BUCKET,
|
|
303
|
+
project: 'todomvc',
|
|
304
|
+
ingestToken: env.VARIANCE_INGEST_TOKEN,
|
|
305
|
+
reviewToken: env.VARIANCE_REVIEW_TOKEN,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export const { GET, POST, HEAD } = createTribunalRoutes(worker, {
|
|
309
|
+
basePath: '/variance',
|
|
310
|
+
authorize: async (request: Request) => ((await isSignedIn(request)) ? 'review' : null),
|
|
311
|
+
tokens: { ingest: env.VARIANCE_INGEST_TOKEN, review: env.VARIANCE_REVIEW_TOKEN },
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`createReviewClient` takes `endpoint` — where the Worker is mounted — and an
|
|
316
|
+
optional `token` for a caller holding the review token directly. Omit `token`
|
|
317
|
+
behind the Next.js adapter above: the server route holds it and the browser
|
|
318
|
+
never sees it.
|
|
319
|
+
|
|
320
|
+
`createTribunalRoutes` takes `basePath`, `authorize` and `tokens`. `basePath` is
|
|
321
|
+
stripped before the request reaches the Worker. `tokens` is passed explicitly
|
|
322
|
+
because the `Tribunal` object only exposes `fetch`, not the tokens it was
|
|
323
|
+
created with.
|
|
324
|
+
|
|
325
|
+
`authorize` has **no default**. It returns `'ingest'`, `'review'`, or `null`; a
|
|
326
|
+
refused caller gets a 401 before the Worker sees the request, and a caller
|
|
327
|
+
returned `'review'` has the review token attached on their behalf.
|
|
328
|
+
|
|
329
|
+
### `GET /review/changelog`
|
|
330
|
+
|
|
331
|
+
A build says what changed today; this endpoint says what was *approved*,
|
|
332
|
+
grouped by shape rather than by which screenshot changed — the same grouping
|
|
333
|
+
the docket (the reviewer's ranked list of causes, described below) uses, so a
|
|
334
|
+
token edit across forty stories is one entry, not forty.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { createReviewStore } from '@variance-authority/tribunal/review';
|
|
338
|
+
import { createMemoryR2, createSqliteD1 } from '@variance-authority/tribunal/testing';
|
|
339
|
+
|
|
340
|
+
const review = createReviewStore({
|
|
341
|
+
db: await createSqliteD1(),
|
|
342
|
+
bucket: createMemoryR2(),
|
|
343
|
+
project: 'todomvc',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const { changes, ungrouped } = await review.changelog({ component: 'Card', limit: 50 });
|
|
347
|
+
|
|
348
|
+
changes[0]?.subjects; // approved subjects this shape landed in, newest first
|
|
349
|
+
changes[0]?.by; // everyone who approved part of it
|
|
350
|
+
changes[0]?.builds; // where the approvals came from
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`changelog` takes `component` (substring, case-insensitive), `subject` (exact),
|
|
354
|
+
`since` (ISO 8601) and `limit` (default 500). The route takes the same four as
|
|
355
|
+
query parameters.
|
|
356
|
+
|
|
357
|
+
**One row is written per approval, and its columns are copies rather than a
|
|
358
|
+
join.** The regions, the commit, the intent and the reviewer are frozen at the
|
|
359
|
+
moment of approval rather than read live from `builds` and `build_subjects`,
|
|
360
|
+
because a `sweep` (see Retention below) removes builds, and a baseline's
|
|
361
|
+
explanation has to outlive them.
|
|
362
|
+
|
|
363
|
+
**Nothing is written for a rejection.** It is recorded in `decisions`, but no
|
|
364
|
+
baseline changed.
|
|
365
|
+
|
|
366
|
+
**Shapes are grouped when somebody reads**, not when a row is written — approval
|
|
367
|
+
here is per subject, so there is no batch at write time to cluster, and a shape
|
|
368
|
+
approved across several sessions still reads as one change. Approved subjects
|
|
369
|
+
that no shape could group are returned as `ungrouped` rather than dropped.
|
|
370
|
+
|
|
371
|
+
## Review surface
|
|
372
|
+
|
|
373
|
+
When `ReviewApp` (`@variance-authority/tribunal/ui`) is open on a build, a
|
|
374
|
+
reviewer sees, in this order:
|
|
375
|
+
|
|
376
|
+
1. **The docket** — one entry per component the semantic tier (the analysis step
|
|
377
|
+
that attributes a changed region to a component, rather than just measuring
|
|
378
|
+
pixels) named as a *cause*, largest first, with the file each is declared in.
|
|
379
|
+
Collateral is one number for the build rather than a per-region list.
|
|
380
|
+
2. **The regions, drawn on the render**, cause and collateral styled apart, each
|
|
381
|
+
labelled with the component that owns it.
|
|
382
|
+
3. **The comparison** — swipe, onion, side-by-side, difference mask — last, and
|
|
383
|
+
only the modes this build actually kept images for.
|
|
384
|
+
4. **The record**, on request per subject: how often this subject has failed to
|
|
385
|
+
read the same way twice, how often its cause has caused an approved change,
|
|
386
|
+
and how many subjects that component reaches. Fetched when a reviewer clicks
|
|
387
|
+
*Has this changed before?* rather than with the build — a build with three
|
|
388
|
+
hundred changed subjects would otherwise make nine hundred history requests to
|
|
389
|
+
draw a page on which one is read.
|
|
390
|
+
|
|
391
|
+
The docket ranks by cause pixels rather than total area, so a large container
|
|
392
|
+
that only reflowed does not outrank the smaller edit that caused it; `cause` is
|
|
393
|
+
a field on a region rather than something inferred from a component's size.
|
|
394
|
+
|
|
395
|
+
Step 4 is the question a before-and-after cannot answer. The same 2px shift is a
|
|
396
|
+
bug in a component nobody has touched since March and a Tuesday in one that moves
|
|
397
|
+
in nineteen runs out of twenty. Two numbers are drawn as missing rather than as
|
|
398
|
+
zero, deliberately: a flake rate is **absent** until a run has read every subject
|
|
399
|
+
twice (`RunRecord.swept`), and a coverage that was never stated is unknown rather
|
|
400
|
+
than clean.
|
|
401
|
+
|
|
402
|
+
The `Changelog` tab is the same evidence at project scale — every approval,
|
|
403
|
+
grouped by the shape that was approved, with the approvals nothing could
|
|
404
|
+
attribute listed rather than dropped.
|
|
405
|
+
|
|
406
|
+
## Review invariants
|
|
407
|
+
|
|
408
|
+
**Approval promotes an image; it never records one.** Deciding *approved* makes
|
|
409
|
+
that build's uploaded candidate the baseline, through the same `RasterStore` the
|
|
410
|
+
next run reads. A subject whose candidate was never uploaded **cannot be
|
|
411
|
+
approved**.
|
|
412
|
+
|
|
413
|
+
**A store failure is never a verdict.** Every D1 and R2 failure raises
|
|
414
|
+
`RasterStoreError` rather than returning a value; `null` is reserved for *the
|
|
415
|
+
store looked and there is no baseline*.
|
|
416
|
+
|
|
417
|
+
**A row without its object is damage, not absence.** The sidecar (metadata)
|
|
418
|
+
lives in D1 and the image in R2. `describe` spends an R2 `head` call to confirm
|
|
419
|
+
the object still exists rather than answering from the D1 row alone.
|
|
420
|
+
|
|
421
|
+
**A coverage list that was never stated is not an empty one.** `undefined` and
|
|
422
|
+
`[]` are stored, returned, and drawn as different values: absent means nothing
|
|
423
|
+
looked, `[]` means inspected and clean. The same distinction holds for findings.
|
|
424
|
+
|
|
425
|
+
**The review token reads the record and never writes it.** `/v1/churn`,
|
|
426
|
+
`/v1/reach`, `/v1/flakiness`, `/v1/value-journey` and `/v1/last-changed` answer
|
|
427
|
+
either capability, because they derive from rows already recorded and the browser
|
|
428
|
+
drawing a review page holds the review token. `/v1/observations`, `/v1/approvals`
|
|
429
|
+
and `/v1/current` are the ingest token's: the first two write, and the third is a
|
|
430
|
+
run asking what to write against.
|
|
431
|
+
|
|
432
|
+
**Two tokens, and they may not be equal.** The ingest token lives in CI
|
|
433
|
+
configuration and writes builds, baselines and history; the review token
|
|
434
|
+
belongs to people and decides. Construction refuses a token under 16 characters
|
|
435
|
+
and refuses two identical tokens.
|
|
436
|
+
|
|
437
|
+
**Authentication happens before routing.** A caller holding neither token gets
|
|
438
|
+
one identical response for a wrong token, a missing token, and a path that does
|
|
439
|
+
not exist; which of the two tokens a route wants is only revealed to a caller
|
|
440
|
+
who already holds a valid one.
|
|
441
|
+
|
|
442
|
+
**The Worker makes no outbound request.** Not a status check, not a PR comment,
|
|
443
|
+
not a webhook, not telemetry — the pipeline reports to the server, and the
|
|
444
|
+
server reports to nobody.
|
|
445
|
+
|
|
446
|
+
**The record is append-only, and the database enforces it.** Runs,
|
|
447
|
+
observations, token values and decisions all carry `UPDATE` and `DELETE`
|
|
448
|
+
triggers, so a direct `wrangler d1 execute` against the database is refused
|
|
449
|
+
too.
|
|
450
|
+
|
|
451
|
+
## Retention
|
|
452
|
+
|
|
453
|
+
`POST /review/sweep?days=N` removes builds older than `N` days: their subject
|
|
454
|
+
rows, their coverage rows, and every image they kept. It **reports counts** for
|
|
455
|
+
everything it removed — `builds`, `subjects`, `objects`.
|
|
456
|
+
|
|
457
|
+
What it does not remove: **promoted baselines** (what the next run compares
|
|
458
|
+
against), **decisions**, and the **changelog**. The fourth count,
|
|
459
|
+
`decisionsKept`, is how many approvals outlived the builds this call removed,
|
|
460
|
+
not a fourth removal.
|
|
461
|
+
|
|
462
|
+
It runs on request only, never on a timer — wire it to a scheduled trigger or
|
|
463
|
+
call it from CI.
|
|
464
|
+
|
|
465
|
+
## Testing your own wiring
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { createReviewStore } from '@variance-authority/tribunal/review';
|
|
469
|
+
import { createMemoryR2, createSqliteD1 } from '@variance-authority/tribunal/testing';
|
|
470
|
+
|
|
471
|
+
const review = createReviewStore({
|
|
472
|
+
db: await createSqliteD1(),
|
|
473
|
+
bucket: createMemoryR2(),
|
|
474
|
+
project: 'todomvc',
|
|
475
|
+
});
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
D1 *is* SQLite, so this adapter runs the package's schema, indexes, triggers,
|
|
479
|
+
`ON CONFLICT` clauses and ordering through `node:sqlite`.
|
|
480
|
+
`createMemoryR2().fail(…)` makes the bucket throw, allowing a host to exercise
|
|
481
|
+
its store-failure path.
|
|
482
|
+
|
|
483
|
+
That is also what an operator wants: their routes, their ingest, their retention
|
|
484
|
+
settings, in a plain `vitest` process, with no `wrangler`, no container and no
|
|
485
|
+
account.
|
|
486
|
+
|
|
487
|
+
## Operational boundaries
|
|
488
|
+
|
|
489
|
+
The Worker relies on the platform's D1 transaction and `batch` semantics,
|
|
490
|
+
request and subrequest limits, object-size ceilings, quotas, and the behavior of
|
|
491
|
+
concurrent writes. Those are deployment conditions, not behavior this package
|
|
492
|
+
can configure or infer.
|
|
493
|
+
|
|
494
|
+
**Concurrency around run lineage is weaker than the SQLite backend's.** The
|
|
495
|
+
SQLite backend takes a write lock before checking whether a run id is
|
|
496
|
+
registered; two Workers cannot. A unique index still refuses a run id pointing
|
|
497
|
+
at two commits either way.
|
|
498
|
+
|
|
499
|
+
**A build's images are as large as the run kept.** Nothing here compresses,
|
|
500
|
+
resizes, or deduplicates across builds.
|
|
501
|
+
|
|
502
|
+
**A build cannot distinguish two images of one subject.** `ObservationRecord`
|
|
503
|
+
(the per-subject outcome a run reports — subject id, verdict, regions) carries
|
|
504
|
+
a subject and no label, so labelled baselines are writable through the store
|
|
505
|
+
and not reachable through the review path.
|
|
506
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One report, and the build a run would have posted from it.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the files that test [`review.ts`](../review.ts) rather than copied
|
|
5
|
+
* into each, because the point of every one of them is what the store does with
|
|
6
|
+
* *this* report — the same three subjects, the same 86 cause pixels against 511
|
|
7
|
+
* collateral. A second copy that drifted by one pixel would turn an ordering
|
|
8
|
+
* claim into two claims about two reports.
|
|
9
|
+
*/
|
|
10
|
+
import type { RenderIdentity } from '@variance-authority/core';
|
|
11
|
+
import type { RunReport, VariationRecord } from '@variance-authority/report';
|
|
12
|
+
import { type BuildIngest, type ReviewStore } from '../review.js';
|
|
13
|
+
import { type MemoryR2, type SqliteD1 } from '../testing.js';
|
|
14
|
+
export declare const IDENTITY: RenderIdentity;
|
|
15
|
+
export declare const CANDIDATE: string;
|
|
16
|
+
export declare const PREVIOUS: string;
|
|
17
|
+
export declare function image(colour: readonly [number, number, number]): string;
|
|
18
|
+
/**
|
|
19
|
+
* A report as `variance run` writes one: two subjects changed by one edit, where
|
|
20
|
+
* the reflowed container carries six times the pixels of the component that was
|
|
21
|
+
* actually edited.
|
|
22
|
+
*/
|
|
23
|
+
export declare function report(overrides?: Partial<RunReport>): RunReport;
|
|
24
|
+
export declare function ingest(overrides?: Partial<BuildIngest>): BuildIngest;
|
|
25
|
+
/**
|
|
26
|
+
* The three states a variation arrives in, in one report.
|
|
27
|
+
*
|
|
28
|
+
* Together they are the distinction the table keeps a nullable column for: an arm
|
|
29
|
+
* that was measured and differs, one that was measured and reaches nothing, and
|
|
30
|
+
* one nothing could measure because the parent it named is not in this run.
|
|
31
|
+
*/
|
|
32
|
+
export declare const VARIATIONS: readonly VariationRecord[];
|
|
33
|
+
/** The hour a build in these fixtures was posted at. */
|
|
34
|
+
export declare const POSTED = "2026-06-01T12:00:00.000Z";
|
|
35
|
+
/**
|
|
36
|
+
* A store over an empty database and an empty bucket.
|
|
37
|
+
*
|
|
38
|
+
* `now` is a getter rather than a date because the tests that reach retention
|
|
39
|
+
* move the clock after the build was written, which is the only way to have a
|
|
40
|
+
* build that is old without waiting for it to become one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function openReview(now?: () => Date): Promise<{
|
|
43
|
+
db: SqliteD1;
|
|
44
|
+
bucket: MemoryR2;
|
|
45
|
+
review: ReviewStore;
|
|
46
|
+
}>;
|
|
47
|
+
//# sourceMappingURL=review.d.ts.map
|