@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.
Files changed (271) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.md +506 -0
  4. package/dist/__fixtures__/review.d.ts +47 -0
  5. package/dist/__fixtures__/review.js +145 -0
  6. package/dist/__fixtures__/review.js.map +1 -0
  7. package/dist/bindings.d.ts +134 -0
  8. package/dist/bindings.js +88 -0
  9. package/dist/bindings.js.map +1 -0
  10. package/dist/changelog.d.ts +132 -0
  11. package/dist/changelog.js +129 -0
  12. package/dist/changelog.js.map +1 -0
  13. package/dist/history-queries.d.ts +82 -0
  14. package/dist/history-queries.js +200 -0
  15. package/dist/history-queries.js.map +1 -0
  16. package/dist/history-rows.d.ts +42 -0
  17. package/dist/history-rows.js +148 -0
  18. package/dist/history-rows.js.map +1 -0
  19. package/dist/history.d.ts +40 -0
  20. package/dist/history.js +186 -0
  21. package/dist/history.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/migration-steps.d.ts +32 -0
  26. package/dist/migration-steps.js +303 -0
  27. package/dist/migration-steps.js.map +1 -0
  28. package/dist/migrations.d.ts +58 -0
  29. package/dist/migrations.js +283 -0
  30. package/dist/migrations.js.map +1 -0
  31. package/dist/next.d.ts +94 -0
  32. package/dist/next.js +44 -0
  33. package/dist/next.js.map +1 -0
  34. package/dist/node/bin.d.ts +120 -0
  35. package/dist/node/bin.js +337 -0
  36. package/dist/node/bin.js.map +1 -0
  37. package/dist/node/bucket.d.ts +58 -0
  38. package/dist/node/bucket.js +142 -0
  39. package/dist/node/bucket.js.map +1 -0
  40. package/dist/node/database.d.ts +33 -0
  41. package/dist/node/database.js +157 -0
  42. package/dist/node/database.js.map +1 -0
  43. package/dist/node/index.d.ts +43 -0
  44. package/dist/node/index.js +43 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/node/serve.d.ts +81 -0
  47. package/dist/node/serve.js +193 -0
  48. package/dist/node/serve.js.map +1 -0
  49. package/dist/node/ui-assets.d.ts +47 -0
  50. package/dist/node/ui-assets.js +60 -0
  51. package/dist/node/ui-assets.js.map +1 -0
  52. package/dist/review-ingest.d.ts +24 -0
  53. package/dist/review-ingest.js +184 -0
  54. package/dist/review-ingest.js.map +1 -0
  55. package/dist/review-read.d.ts +87 -0
  56. package/dist/review-read.js +361 -0
  57. package/dist/review-read.js.map +1 -0
  58. package/dist/review-rows.d.ts +30 -0
  59. package/dist/review-rows.js +67 -0
  60. package/dist/review-rows.js.map +1 -0
  61. package/dist/review-types.d.ts +414 -0
  62. package/dist/review-types.js +2 -0
  63. package/dist/review-types.js.map +1 -0
  64. package/dist/review-write.d.ts +35 -0
  65. package/dist/review-write.js +86 -0
  66. package/dist/review-write.js.map +1 -0
  67. package/dist/review.d.ts +53 -0
  68. package/dist/review.fixtures.d.ts +47 -0
  69. package/dist/review.fixtures.js +145 -0
  70. package/dist/review.fixtures.js.map +1 -0
  71. package/dist/review.js +245 -0
  72. package/dist/review.js.map +1 -0
  73. package/dist/schema.d.ts +36 -0
  74. package/dist/schema.js +39 -0
  75. package/dist/schema.js.map +1 -0
  76. package/dist/store.d.ts +58 -0
  77. package/dist/store.js +278 -0
  78. package/dist/store.js.map +1 -0
  79. package/dist/testing.d.ts +38 -0
  80. package/dist/testing.js +92 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/ui/__probe.d.ts +3 -0
  83. package/dist/ui/__probe.js +5 -0
  84. package/dist/ui/__probe.js.map +1 -0
  85. package/dist/ui/attribution.d.ts +46 -0
  86. package/dist/ui/attribution.js +79 -0
  87. package/dist/ui/attribution.js.map +1 -0
  88. package/dist/ui/because.d.ts +51 -0
  89. package/dist/ui/because.js +52 -0
  90. package/dist/ui/because.js.map +1 -0
  91. package/dist/ui/browser-entry.d.ts +2 -0
  92. package/dist/ui/browser-entry.js +34 -0
  93. package/dist/ui/browser-entry.js.map +1 -0
  94. package/dist/ui/builds.d.ts +43 -0
  95. package/dist/ui/builds.js +79 -0
  96. package/dist/ui/builds.js.map +1 -0
  97. package/dist/ui/carried.d.ts +73 -0
  98. package/dist/ui/carried.js +63 -0
  99. package/dist/ui/carried.js.map +1 -0
  100. package/dist/ui/cause.d.ts +73 -0
  101. package/dist/ui/cause.js +133 -0
  102. package/dist/ui/cause.js.map +1 -0
  103. package/dist/ui/change-story.d.ts +153 -0
  104. package/dist/ui/change-story.js +253 -0
  105. package/dist/ui/change-story.js.map +1 -0
  106. package/dist/ui/change.d.ts +51 -0
  107. package/dist/ui/change.js +125 -0
  108. package/dist/ui/change.js.map +1 -0
  109. package/dist/ui/client.d.ts +74 -0
  110. package/dist/ui/client.js +88 -0
  111. package/dist/ui/client.js.map +1 -0
  112. package/dist/ui/consumers.d.ts +80 -0
  113. package/dist/ui/consumers.js +125 -0
  114. package/dist/ui/consumers.js.map +1 -0
  115. package/dist/ui/control.d.ts +63 -0
  116. package/dist/ui/control.js +39 -0
  117. package/dist/ui/control.js.map +1 -0
  118. package/dist/ui/crossing.d.ts +55 -0
  119. package/dist/ui/crossing.js +74 -0
  120. package/dist/ui/crossing.js.map +1 -0
  121. package/dist/ui/declarations.d.ts +36 -0
  122. package/dist/ui/declarations.js +73 -0
  123. package/dist/ui/declarations.js.map +1 -0
  124. package/dist/ui/distance.d.ts +154 -0
  125. package/dist/ui/distance.js +180 -0
  126. package/dist/ui/distance.js.map +1 -0
  127. package/dist/ui/divergence.d.ts +40 -0
  128. package/dist/ui/divergence.js +176 -0
  129. package/dist/ui/divergence.js.map +1 -0
  130. package/dist/ui/docket.d.ts +39 -0
  131. package/dist/ui/docket.js +172 -0
  132. package/dist/ui/docket.js.map +1 -0
  133. package/dist/ui/findings.d.ts +42 -0
  134. package/dist/ui/findings.js +110 -0
  135. package/dist/ui/findings.js.map +1 -0
  136. package/dist/ui/foreseen.d.ts +55 -0
  137. package/dist/ui/foreseen.js +91 -0
  138. package/dist/ui/foreseen.js.map +1 -0
  139. package/dist/ui/glance.d.ts +101 -0
  140. package/dist/ui/glance.js +101 -0
  141. package/dist/ui/glance.js.map +1 -0
  142. package/dist/ui/grouping.d.ts +158 -0
  143. package/dist/ui/grouping.js +187 -0
  144. package/dist/ui/grouping.js.map +1 -0
  145. package/dist/ui/handed.d.ts +60 -0
  146. package/dist/ui/handed.js +122 -0
  147. package/dist/ui/handed.js.map +1 -0
  148. package/dist/ui/history.d.ts +88 -0
  149. package/dist/ui/history.js +170 -0
  150. package/dist/ui/history.js.map +1 -0
  151. package/dist/ui/holding.d.ts +84 -0
  152. package/dist/ui/holding.js +90 -0
  153. package/dist/ui/holding.js.map +1 -0
  154. package/dist/ui/impact.d.ts +31 -0
  155. package/dist/ui/impact.js +132 -0
  156. package/dist/ui/impact.js.map +1 -0
  157. package/dist/ui/index.d.ts +21 -0
  158. package/dist/ui/index.js +21 -0
  159. package/dist/ui/index.js.map +1 -0
  160. package/dist/ui/lead.d.ts +35 -0
  161. package/dist/ui/lead.js +34 -0
  162. package/dist/ui/lead.js.map +1 -0
  163. package/dist/ui/look.d.ts +109 -0
  164. package/dist/ui/look.js +248 -0
  165. package/dist/ui/look.js.map +1 -0
  166. package/dist/ui/mark.d.ts +11 -0
  167. package/dist/ui/mark.js +13 -0
  168. package/dist/ui/mark.js.map +1 -0
  169. package/dist/ui/moved.d.ts +121 -0
  170. package/dist/ui/moved.js +143 -0
  171. package/dist/ui/moved.js.map +1 -0
  172. package/dist/ui/navigation.d.ts +26 -0
  173. package/dist/ui/navigation.js +53 -0
  174. package/dist/ui/navigation.js.map +1 -0
  175. package/dist/ui/order.d.ts +113 -0
  176. package/dist/ui/order.js +226 -0
  177. package/dist/ui/order.js.map +1 -0
  178. package/dist/ui/origins.d.ts +42 -0
  179. package/dist/ui/origins.js +149 -0
  180. package/dist/ui/origins.js.map +1 -0
  181. package/dist/ui/outcome.d.ts +93 -0
  182. package/dist/ui/outcome.js +162 -0
  183. package/dist/ui/outcome.js.map +1 -0
  184. package/dist/ui/parted.d.ts +68 -0
  185. package/dist/ui/parted.js +101 -0
  186. package/dist/ui/parted.js.map +1 -0
  187. package/dist/ui/rail.d.ts +31 -0
  188. package/dist/ui/rail.js +21 -0
  189. package/dist/ui/rail.js.map +1 -0
  190. package/dist/ui/reach.d.ts +66 -0
  191. package/dist/ui/reach.js +185 -0
  192. package/dist/ui/reach.js.map +1 -0
  193. package/dist/ui/regions.d.ts +82 -0
  194. package/dist/ui/regions.js +67 -0
  195. package/dist/ui/regions.js.map +1 -0
  196. package/dist/ui/review.bundle.js +69 -0
  197. package/dist/ui/review.d.ts +97 -0
  198. package/dist/ui/review.js +39 -0
  199. package/dist/ui/review.js.map +1 -0
  200. package/dist/ui/root.d.ts +72 -0
  201. package/dist/ui/root.js +102 -0
  202. package/dist/ui/root.js.map +1 -0
  203. package/dist/ui/route.d.ts +95 -0
  204. package/dist/ui/route.js +114 -0
  205. package/dist/ui/route.js.map +1 -0
  206. package/dist/ui/run.d.ts +26 -0
  207. package/dist/ui/run.js +12 -0
  208. package/dist/ui/run.js.map +1 -0
  209. package/dist/ui/sense.d.ts +187 -0
  210. package/dist/ui/sense.js +222 -0
  211. package/dist/ui/sense.js.map +1 -0
  212. package/dist/ui/settled.d.ts +43 -0
  213. package/dist/ui/settled.js +59 -0
  214. package/dist/ui/settled.js.map +1 -0
  215. package/dist/ui/shell.d.ts +76 -0
  216. package/dist/ui/shell.js +65 -0
  217. package/dist/ui/shell.js.map +1 -0
  218. package/dist/ui/shift.d.ts +52 -0
  219. package/dist/ui/shift.js +98 -0
  220. package/dist/ui/shift.js.map +1 -0
  221. package/dist/ui/styles-docket.d.ts +18 -0
  222. package/dist/ui/styles-docket.js +281 -0
  223. package/dist/ui/styles-docket.js.map +1 -0
  224. package/dist/ui/styles-stage.d.ts +18 -0
  225. package/dist/ui/styles-stage.js +92 -0
  226. package/dist/ui/styles-stage.js.map +1 -0
  227. package/dist/ui/styles.d.ts +12 -0
  228. package/dist/ui/styles.js +482 -0
  229. package/dist/ui/styles.js.map +1 -0
  230. package/dist/ui/subject.d.ts +36 -0
  231. package/dist/ui/subject.js +138 -0
  232. package/dist/ui/subject.js.map +1 -0
  233. package/dist/ui/text.d.ts +129 -0
  234. package/dist/ui/text.js +164 -0
  235. package/dist/ui/text.js.map +1 -0
  236. package/dist/ui/variations.d.ts +37 -0
  237. package/dist/ui/variations.js +60 -0
  238. package/dist/ui/variations.js.map +1 -0
  239. package/dist/ui/viewer.d.ts +67 -0
  240. package/dist/ui/viewer.js +246 -0
  241. package/dist/ui/viewer.js.map +1 -0
  242. package/dist/worker-auth.d.ts +48 -0
  243. package/dist/worker-auth.js +74 -0
  244. package/dist/worker-auth.js.map +1 -0
  245. package/dist/worker-entry.d.ts +58 -0
  246. package/dist/worker-entry.js +36 -0
  247. package/dist/worker-entry.js.map +1 -0
  248. package/dist/worker-http.d.ts +39 -0
  249. package/dist/worker-http.js +111 -0
  250. package/dist/worker-http.js.map +1 -0
  251. package/dist/worker-input.d.ts +81 -0
  252. package/dist/worker-input.js +296 -0
  253. package/dist/worker-input.js.map +1 -0
  254. package/dist/worker.d.ts +91 -0
  255. package/dist/worker.js +287 -0
  256. package/dist/worker.js.map +1 -0
  257. package/mark.svg +30 -0
  258. package/migrations/0001_initial.sql +193 -0
  259. package/migrations/0002_changelog.sql +30 -0
  260. package/migrations/0003_accessibility.sql +8 -0
  261. package/migrations/0004_variations.sql +19 -0
  262. package/migrations/0005_reach.sql +26 -0
  263. package/migrations/0006_baseline-size.sql +7 -0
  264. package/migrations/0007_declarations.sql +7 -0
  265. package/migrations/0008_green-because.sql +7 -0
  266. package/migrations/0009_moved-bands.sql +6 -0
  267. package/migrations/0010_composition.sql +15 -0
  268. package/migrations/0011_movements.sql +21 -0
  269. package/migrations/0012_control-size.sql +6 -0
  270. package/package.json +110 -0
  271. package/wrangler.jsonc +45 -0
@@ -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
@@ -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,3 @@
1
+ import type { BuildDetail } from '../review.js';
2
+ export declare function probe(build: BuildDetail): number;
3
+ //# sourceMappingURL=__probe.d.ts.map
@@ -0,0 +1,5 @@
1
+ import { clusterChanges } from '@variance-authority/report';
2
+ export function probe(build) {
3
+ return clusterChanges(build.subjects).changes.length;
4
+ }
5
+ //# sourceMappingURL=__probe.js.map
@@ -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