@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { clusterChanges } from '@variance-authority/report';
|
|
2
|
+
import { ReviewError, instant, optionalText, text } from './review-rows.js';
|
|
3
|
+
const DEFAULT_LIMIT = 500;
|
|
4
|
+
/**
|
|
5
|
+
* Freeze the explanation of one approval.
|
|
6
|
+
*
|
|
7
|
+
* Called from `decide` after the promotion and before the decision row, in the
|
|
8
|
+
* same order and for the same reason: the promotion is the thing with a
|
|
9
|
+
* consequence, and a changelog entry for a baseline that was never written would
|
|
10
|
+
* be an explanation of nothing.
|
|
11
|
+
*
|
|
12
|
+
* Never invents a build's `commit` or `intent` — both come from the build row the
|
|
13
|
+
* approval is about, and a build that cannot be read is a refusal in `promote`
|
|
14
|
+
* long before this runs.
|
|
15
|
+
*/
|
|
16
|
+
export async function recordApproval(db, project, input) {
|
|
17
|
+
await db
|
|
18
|
+
.prepare(`INSERT INTO changelog
|
|
19
|
+
(project, build, subject, "commit", intent, decided_by, note, regions, at, at_ms)
|
|
20
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
21
|
+
.bind(project, input.build, input.subject, input.commit, input.intent ?? null, input.by, input.note ?? null, input.regions, input.at, instant(input.at, 'an approval'))
|
|
22
|
+
.run();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every baseline this project has promoted, grouped by what changed.
|
|
26
|
+
*
|
|
27
|
+
* The filters narrow what is *read*, never what is explained: `--subject` selects
|
|
28
|
+
* rows and then the shapes those rows carry are grouped whole, so a shape
|
|
29
|
+
* approved in forty subjects still reports forty when you ask about one of them.
|
|
30
|
+
* Answering with a group of one would say the change was smaller than it was.
|
|
31
|
+
*/
|
|
32
|
+
export async function readChangelog(db, project, query = {}) {
|
|
33
|
+
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
34
|
+
const conditions = ['project = ?'];
|
|
35
|
+
const bindings = [project];
|
|
36
|
+
if (query.subject !== undefined) {
|
|
37
|
+
conditions.push('subject = ?');
|
|
38
|
+
bindings.push(query.subject);
|
|
39
|
+
}
|
|
40
|
+
if (query.since !== undefined) {
|
|
41
|
+
conditions.push('at_ms >= ?');
|
|
42
|
+
bindings.push(instant(query.since, 'a changelog window'));
|
|
43
|
+
}
|
|
44
|
+
const found = await db
|
|
45
|
+
.prepare(`SELECT * FROM changelog WHERE ${conditions.join(' AND ')} ORDER BY at_ms DESC, seq DESC LIMIT ?`)
|
|
46
|
+
.bind(...bindings, limit)
|
|
47
|
+
.all();
|
|
48
|
+
const rows = found.results.map(toRow);
|
|
49
|
+
const clustering = clusterChanges(rows.map(toObservation));
|
|
50
|
+
const bySubject = new Map(rows.map((row) => [row.subject, row]));
|
|
51
|
+
const grouped = new Set();
|
|
52
|
+
const changes = [];
|
|
53
|
+
for (const change of clustering.changes) {
|
|
54
|
+
const members = change.subjects
|
|
55
|
+
.map((subject) => bySubject.get(subject))
|
|
56
|
+
.filter((row) => row !== undefined);
|
|
57
|
+
if (members.length === 0)
|
|
58
|
+
continue;
|
|
59
|
+
if (query.component !== undefined &&
|
|
60
|
+
!(change.component ?? '').toLowerCase().includes(query.component.toLowerCase())) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const member of members)
|
|
64
|
+
grouped.add(member.subject);
|
|
65
|
+
// Newest first, matching the row order the query asked for: the reader is
|
|
66
|
+
// looking at a history, and the top of it is where they are.
|
|
67
|
+
const newest = members.reduce((a, b) => (a.at >= b.at ? a : b));
|
|
68
|
+
changes.push({
|
|
69
|
+
fingerprint: change.fingerprint,
|
|
70
|
+
...(change.component !== undefined ? { component: change.component } : {}),
|
|
71
|
+
...(change.file !== undefined ? { file: change.file } : {}),
|
|
72
|
+
subjects: members.map((member) => member.subject),
|
|
73
|
+
builds: [...new Set(members.map((member) => member.build))],
|
|
74
|
+
by: [...new Set(members.map((member) => member.by))],
|
|
75
|
+
at: newest.at,
|
|
76
|
+
...(newest.intent !== undefined ? { intent: newest.intent } : {}),
|
|
77
|
+
...(newest.note !== undefined ? { note: newest.note } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
changes.sort((a, b) => (a.at === b.at ? 0 : a.at > b.at ? -1 : 1));
|
|
81
|
+
return {
|
|
82
|
+
changes,
|
|
83
|
+
// Only when nothing narrowed the grouping. Under `--component` an ungrouped
|
|
84
|
+
// row is not "a change no shape explained", it is a row the filter is not
|
|
85
|
+
// about, and listing it would answer a question nobody asked.
|
|
86
|
+
ungrouped: query.component === undefined ? rows.filter((row) => !grouped.has(row.subject)) : [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** A stored row, validated rather than cast. */
|
|
90
|
+
function toRow(row) {
|
|
91
|
+
const regions = JSON.parse(text(row, 'regions', 'a changelog entry'));
|
|
92
|
+
if (!Array.isArray(regions)) {
|
|
93
|
+
throw new ReviewError('a changelog entry has a `regions` that is not a list');
|
|
94
|
+
}
|
|
95
|
+
const intent = optionalText(row, 'intent', 'a changelog entry');
|
|
96
|
+
const note = optionalText(row, 'note', 'a changelog entry');
|
|
97
|
+
return {
|
|
98
|
+
build: text(row, 'build', 'a changelog entry'),
|
|
99
|
+
subject: text(row, 'subject', 'a changelog entry'),
|
|
100
|
+
commit: text(row, 'commit', 'a changelog entry'),
|
|
101
|
+
by: text(row, 'decided_by', 'a changelog entry'),
|
|
102
|
+
at: text(row, 'at', 'a changelog entry'),
|
|
103
|
+
regions: regions,
|
|
104
|
+
...(intent !== undefined ? { intent } : {}),
|
|
105
|
+
...(note !== undefined ? { note } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A row, in the shape the clustering already understands.
|
|
110
|
+
*
|
|
111
|
+
* The verdict and the sentence are the row's own fact — it is here because
|
|
112
|
+
* somebody approved a change — rather than a reconstruction of what the run said.
|
|
113
|
+
* `clusterChanges` reads subjects and regions and nothing else, so nothing about
|
|
114
|
+
* the rest of this shape can affect a grouping.
|
|
115
|
+
*/
|
|
116
|
+
function toObservation(row) {
|
|
117
|
+
return {
|
|
118
|
+
subject: row.subject,
|
|
119
|
+
verdict: 'changed',
|
|
120
|
+
because: `approved by ${row.by}`,
|
|
121
|
+
// Summed from the regions rather than stored beside them. `ObservationRecord`
|
|
122
|
+
// asks for a total because a report has one; this is the arithmetic that
|
|
123
|
+
// produced it, and doing it here keeps the stored row down to what it should
|
|
124
|
+
// outlive its build carrying.
|
|
125
|
+
changedPixels: row.regions.reduce((total, region) => total + region.pixels, 0),
|
|
126
|
+
regions: row.regions,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=changelog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changelog.js","sourceRoot":"","sources":["../src/changelog.ts"],"names":[],"mappings":"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","sourcesContent":["import { clusterChanges, type ObservationRecord, type RegionRecord } from '@variance-authority/report';\nimport type { D1Like, D1Value } from './bindings.js';\nimport { ReviewError, instant, optionalText, text, type Row } from './review-rows.js';\n\n/**\n * Why a baseline in this database is what it is.\n *\n * The other half of the same subsystem the git-LFS backend answers with a commit\n * message. There, the baseline is a commit and the explanation goes in the\n * message; here, the baseline is a row and the explanation goes in a row beside\n * it. Both are read back by asking the store that holds the bytes, which is the\n * only arrangement in which the explanation cannot drift away from the thing it\n * explains.\n *\n * ## Why it copies rather than joins\n *\n * Everything in this table is already in `build_subjects` and `builds` at the\n * moment it is written, and a view over those two would be shorter. It would also\n * be empty after `sweep`: builds expire — that is what a review surface's storage\n * budget is — and the explanation of a baseline has to outlive the build that\n * proposed it by exactly as long as the baseline lasts, which is forever. So the\n * regions, the commit, the intent and the reviewer are **frozen** into the row at\n * the moment of approval, the same way a commit message is frozen into a commit.\n *\n * A row is written only for an **approval**. A rejection is a decision and it is\n * recorded in `decisions`, but no baseline changed, and a changelog that carried\n * rejections would answer *why does this baseline look like this* with entries\n * about baselines that are not there.\n *\n * ## Clustered on the way out, not on the way in\n *\n * One row per approved subject, and the shapes are grouped when somebody reads.\n * Approval here is per subject — a reviewer clicks through a docket rather than\n * running one command over a report — so there is no moment at which a \"batch\"\n * exists to cluster. Grouping at read time also means a shape approved across\n * three sessions reads as one change, which is what it was.\n */\n\n/** One approved subject, exactly as the build described it. */\nexport interface ChangelogRow {\n readonly build: string;\n readonly subject: string;\n readonly commit: string;\n readonly intent?: string;\n readonly by: string;\n readonly note?: string;\n readonly at: string;\n /**\n * The regions the build reported, frozen.\n *\n * The only measurement kept, and kept because the fingerprints inside it are\n * what a shape *is* — the grouping is recomputed from these on every read. No\n * aggregate sits beside them: a changed-pixel total measures displacement\n * rather than magnitude and is bound to the machine that rendered it, and a\n * column of them in a permanent table is an invitation to compare two numbers\n * that were never comparable.\n */\n readonly regions: readonly RegionRecord[];\n}\n\n/** One change, as it was approved — the same unit the commit-message half uses. */\nexport interface ChangelogChange {\n readonly fingerprint: string;\n readonly component?: string;\n readonly file?: string;\n /**\n * Approved subjects this shape landed in, most recently approved first.\n *\n * Approval order rather than a sort, and the same order as `by` beside it, so\n * the two lists can be read against each other: the reviewer at the top is the\n * one who approved the subject at the top.\n */\n readonly subjects: readonly string[];\n /** The builds the approvals came from, newest first. */\n readonly builds: readonly string[];\n /** Everyone who approved part of this shape, most recent first. */\n readonly by: readonly string[];\n /** The most recent approval in the group. */\n readonly at: string;\n readonly intent?: string;\n readonly note?: string;\n}\n\nexport interface TribunalChangelog {\n readonly changes: readonly ChangelogChange[];\n /**\n * Approved subjects no shape could group.\n *\n * A run that attributed nothing — the ephemeral and raster-only paths — still\n * approved something, and counting it is how the total stays a total. Dropping\n * these would make a changelog that covers eleven of forty approvals look like\n * a changelog of eleven approvals.\n */\n readonly ungrouped: readonly ChangelogRow[];\n}\n\nexport interface TribunalChangelogQuery {\n /** Substring, case-insensitive, against the component a shape was attributed to. */\n readonly component?: string;\n /** Exact, because a subject id is exact. */\n readonly subject?: string;\n /** Rows to read, newest first. Defaults to 500. */\n readonly limit?: number;\n /** Only approvals at or after this instant. ISO 8601. */\n readonly since?: string;\n}\n\nconst DEFAULT_LIMIT = 500;\n\n/**\n * Freeze the explanation of one approval.\n *\n * Called from `decide` after the promotion and before the decision row, in the\n * same order and for the same reason: the promotion is the thing with a\n * consequence, and a changelog entry for a baseline that was never written would\n * be an explanation of nothing.\n *\n * Never invents a build's `commit` or `intent` — both come from the build row the\n * approval is about, and a build that cannot be read is a refusal in `promote`\n * long before this runs.\n */\nexport async function recordApproval(\n db: D1Like,\n project: string,\n input: {\n readonly build: string;\n readonly subject: string;\n readonly by: string;\n readonly note?: string;\n readonly at: string;\n readonly commit: string;\n readonly intent?: string;\n readonly regions: string;\n },\n): Promise<void> {\n await db\n .prepare(\n `INSERT INTO changelog\n (project, build, subject, \"commit\", intent, decided_by, note, regions, at, at_ms)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n )\n .bind(\n project,\n input.build,\n input.subject,\n input.commit,\n input.intent ?? null,\n input.by,\n input.note ?? null,\n input.regions,\n input.at,\n instant(input.at, 'an approval'),\n )\n .run();\n}\n\n/**\n * Every baseline this project has promoted, grouped by what changed.\n *\n * The filters narrow what is *read*, never what is explained: `--subject` selects\n * rows and then the shapes those rows carry are grouped whole, so a shape\n * approved in forty subjects still reports forty when you ask about one of them.\n * Answering with a group of one would say the change was smaller than it was.\n */\nexport async function readChangelog(\n db: D1Like,\n project: string,\n query: TribunalChangelogQuery = {},\n): Promise<TribunalChangelog> {\n const limit = query.limit ?? DEFAULT_LIMIT;\n const conditions = ['project = ?'];\n const bindings: D1Value[] = [project];\n\n if (query.subject !== undefined) {\n conditions.push('subject = ?');\n bindings.push(query.subject);\n }\n if (query.since !== undefined) {\n conditions.push('at_ms >= ?');\n bindings.push(instant(query.since, 'a changelog window'));\n }\n\n const found = await db\n .prepare(\n `SELECT * FROM changelog WHERE ${conditions.join(' AND ')} ORDER BY at_ms DESC, seq DESC LIMIT ?`,\n )\n .bind(...bindings, limit)\n .all<Row>();\n\n const rows = found.results.map(toRow);\n const clustering = clusterChanges(rows.map(toObservation));\n\n const bySubject = new Map(rows.map((row) => [row.subject, row]));\n const grouped = new Set<string>();\n const changes: ChangelogChange[] = [];\n\n for (const change of clustering.changes) {\n const members = change.subjects\n .map((subject) => bySubject.get(subject))\n .filter((row): row is ChangelogRow => row !== undefined);\n if (members.length === 0) continue;\n if (\n query.component !== undefined &&\n !(change.component ?? '').toLowerCase().includes(query.component.toLowerCase())\n ) {\n continue;\n }\n\n for (const member of members) grouped.add(member.subject);\n // Newest first, matching the row order the query asked for: the reader is\n // looking at a history, and the top of it is where they are.\n const newest = members.reduce((a, b) => (a.at >= b.at ? a : b));\n\n changes.push({\n fingerprint: change.fingerprint,\n ...(change.component !== undefined ? { component: change.component } : {}),\n ...(change.file !== undefined ? { file: change.file } : {}),\n subjects: members.map((member) => member.subject),\n builds: [...new Set(members.map((member) => member.build))],\n by: [...new Set(members.map((member) => member.by))],\n at: newest.at,\n ...(newest.intent !== undefined ? { intent: newest.intent } : {}),\n ...(newest.note !== undefined ? { note: newest.note } : {}),\n });\n }\n\n changes.sort((a, b) => (a.at === b.at ? 0 : a.at > b.at ? -1 : 1));\n\n return {\n changes,\n // Only when nothing narrowed the grouping. Under `--component` an ungrouped\n // row is not \"a change no shape explained\", it is a row the filter is not\n // about, and listing it would answer a question nobody asked.\n ungrouped:\n query.component === undefined ? rows.filter((row) => !grouped.has(row.subject)) : [],\n };\n}\n\n/** A stored row, validated rather than cast. */\nfunction toRow(row: Row): ChangelogRow {\n const regions: unknown = JSON.parse(text(row, 'regions', 'a changelog entry'));\n if (!Array.isArray(regions)) {\n throw new ReviewError('a changelog entry has a `regions` that is not a list');\n }\n\n const intent = optionalText(row, 'intent', 'a changelog entry');\n const note = optionalText(row, 'note', 'a changelog entry');\n\n return {\n build: text(row, 'build', 'a changelog entry'),\n subject: text(row, 'subject', 'a changelog entry'),\n commit: text(row, 'commit', 'a changelog entry'),\n by: text(row, 'decided_by', 'a changelog entry'),\n at: text(row, 'at', 'a changelog entry'),\n regions: regions as readonly RegionRecord[],\n ...(intent !== undefined ? { intent } : {}),\n ...(note !== undefined ? { note } : {}),\n };\n}\n\n/**\n * A row, in the shape the clustering already understands.\n *\n * The verdict and the sentence are the row's own fact — it is here because\n * somebody approved a change — rather than a reconstruction of what the run said.\n * `clusterChanges` reads subjects and regions and nothing else, so nothing about\n * the rest of this shape can affect a grouping.\n */\nfunction toObservation(row: ChangelogRow): ObservationRecord {\n return {\n subject: row.subject,\n verdict: 'changed',\n because: `approved by ${row.by}`,\n // Summed from the regions rather than stored beside them. `ObservationRecord`\n // asks for a total because a report has one; this is the arithmetic that\n // produced it, and doing it here keeps the stored row down to what it should\n // outlive its build carrying.\n changedPixels: row.regions.reduce((total, region) => total + region.pixels, 0),\n regions: row.regions,\n };\n}\n"]}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Observation, TokenValue } from '@variance-authority/history';
|
|
2
|
+
import type { AreaQuery, BackendQuery, ComponentWindowQuery, ReachRows, Slice, SubjectsQuery, SubjectWindowQuery, TokenWindowQuery, WindowQuery } from '@variance-authority/server';
|
|
3
|
+
import type { D1Like, D1Value } from './bindings.js';
|
|
4
|
+
import { type Row } from './history-rows.js';
|
|
5
|
+
/**
|
|
6
|
+
* The predicates, and the three reads whose shape is not a plain `SELECT *`.
|
|
7
|
+
*
|
|
8
|
+
* Split from [`history.ts`](./history.ts) for the reason the SQLite backend is
|
|
9
|
+
* split the same way: none of this touches a connection's lifetime or a write.
|
|
10
|
+
* Every function takes a binding and returns rows, so what is under test when
|
|
11
|
+
* these are tested is the SQL — and the file next door stays what it says it is,
|
|
12
|
+
* a transcription of the append path.
|
|
13
|
+
*
|
|
14
|
+
* Transcribed, filter for filter, from
|
|
15
|
+
* [`server/sqlite-queries.ts`](../../server/src/sqlite-queries.ts). The seam
|
|
16
|
+
* exists so that replacing the engine changes what a query *is* and never what a
|
|
17
|
+
* row *means*; the way to honour that is for the two files to be boringly alike.
|
|
18
|
+
*/
|
|
19
|
+
interface Filter {
|
|
20
|
+
readonly sql: string;
|
|
21
|
+
readonly params: readonly D1Value[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A window as SQL, over the parsed instant rather than the stored text.
|
|
25
|
+
*
|
|
26
|
+
* ISO-8601 sorts lexically only while every timestamp shares one offset, and a
|
|
27
|
+
* store fed by CI jobs in two regions does not. A bound that will not parse is
|
|
28
|
+
* refused here rather than bound as `NaN`, which would reach SQLite as NULL,
|
|
29
|
+
* compare false against every row, and return an empty answer that reads as
|
|
30
|
+
* "nothing changed".
|
|
31
|
+
*/
|
|
32
|
+
export declare function windowFilter(query: WindowQuery): Filter;
|
|
33
|
+
export declare function componentFilter(query: ComponentWindowQuery): Filter;
|
|
34
|
+
export declare function subjectFilter(query: SubjectWindowQuery): Filter;
|
|
35
|
+
export declare function tokenFilter(query: TokenWindowQuery): Filter;
|
|
36
|
+
export declare function areaFilter(query: AreaQuery): Filter;
|
|
37
|
+
/**
|
|
38
|
+
* One row per live scope in the named subjects: the newest, and only the newest.
|
|
39
|
+
*
|
|
40
|
+
* Transcribed from `sqlite-queries.ts`, window function included. D1 is SQLite,
|
|
41
|
+
* so `ROW_NUMBER()` is available and the tie-break on `rowid` means the same
|
|
42
|
+
* thing — the later write wins when two rows share an instant, which happens
|
|
43
|
+
* whenever one run recorded two profiles from a single clock read.
|
|
44
|
+
*
|
|
45
|
+
* **No `LIMIT`, in either backend.** A trimmed answer here is not a lower bound
|
|
46
|
+
* that a reader can be warned about; it is a `previous` set with a hole in it, and
|
|
47
|
+
* the run then appends a change that did not happen. The request is what is
|
|
48
|
+
* bounded — `MAX_CURRENT_SUBJECTS` — and the HTTP edge refuses a longer one.
|
|
49
|
+
*/
|
|
50
|
+
export declare function currentRows(db: D1Like, query: SubjectsQuery): Promise<readonly Observation[]>;
|
|
51
|
+
/**
|
|
52
|
+
* The newest recorded value per token, and only the newest.
|
|
53
|
+
*
|
|
54
|
+
* Transcribed from `sqlite-queries.ts`, window function and tie-break included.
|
|
55
|
+
* No `LIMIT`, for the reason `currentRows` has none: a token missing from the
|
|
56
|
+
* answer reads as one nobody has recorded, and the run then writes a value that
|
|
57
|
+
* did not move.
|
|
58
|
+
*/
|
|
59
|
+
export declare function currentTokenRows(db: D1Like, query: BackendQuery): Promise<readonly TokenValue[]>;
|
|
60
|
+
/**
|
|
61
|
+
* The newest rows that fit, returned oldest first, plus the count of the rest.
|
|
62
|
+
*
|
|
63
|
+
* Newest-first selection and chronological return, both deliberate and both
|
|
64
|
+
* transcribed: a limited drift question asks what happened lately, so the *old*
|
|
65
|
+
* end is what gets dropped; and every consumer reads better in the order events
|
|
66
|
+
* happened. What the limit excluded is counted with a second query rather than
|
|
67
|
+
* inferred from a `limit + 1` probe — "there are more" is not a number, and a
|
|
68
|
+
* lower bound whose distance from the truth is unknown is not worth printing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function slice<T>(db: D1Like, table: string, filter: Filter, limit: number | undefined, read: (row: Row) => T): Promise<Slice<T>>;
|
|
71
|
+
/**
|
|
72
|
+
* Reach, in one grouped query plus a correlated lookup outside the window.
|
|
73
|
+
*
|
|
74
|
+
* The correlated subquery is the point: `arrived` asks whether a subject's
|
|
75
|
+
* *first-ever* observation of this component falls inside the window, which the
|
|
76
|
+
* window's own rows cannot answer. Without it, every subject looks new in every
|
|
77
|
+
* window and the one answer a single run genuinely cannot produce degrades into a
|
|
78
|
+
* restatement of the subject list.
|
|
79
|
+
*/
|
|
80
|
+
export declare function reachRows(db: D1Like, query: ComponentWindowQuery): Promise<ReachRows>;
|
|
81
|
+
export {};
|
|
82
|
+
//# sourceMappingURL=history-queries.d.ts.map
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { instant, number, text, toObservation, toTokenValue, } from './history-rows.js';
|
|
2
|
+
/**
|
|
3
|
+
* A window as SQL, over the parsed instant rather than the stored text.
|
|
4
|
+
*
|
|
5
|
+
* ISO-8601 sorts lexically only while every timestamp shares one offset, and a
|
|
6
|
+
* store fed by CI jobs in two regions does not. A bound that will not parse is
|
|
7
|
+
* refused here rather than bound as `NaN`, which would reach SQLite as NULL,
|
|
8
|
+
* compare false against every row, and return an empty answer that reads as
|
|
9
|
+
* "nothing changed".
|
|
10
|
+
*/
|
|
11
|
+
export function windowFilter(query) {
|
|
12
|
+
const clauses = [];
|
|
13
|
+
const params = [];
|
|
14
|
+
scopeInto(query, clauses, params);
|
|
15
|
+
if (query.since !== undefined) {
|
|
16
|
+
clauses.push('at_ms >= ?');
|
|
17
|
+
params.push(instant(query.since, 'window `since`'));
|
|
18
|
+
}
|
|
19
|
+
if (query.until !== undefined) {
|
|
20
|
+
clauses.push('at_ms <= ?');
|
|
21
|
+
params.push(instant(query.until, 'window `until`'));
|
|
22
|
+
}
|
|
23
|
+
return { sql: clauses.map((clause) => ` AND ${clause}`).join(''), params };
|
|
24
|
+
}
|
|
25
|
+
export function componentFilter(query) {
|
|
26
|
+
const base = windowFilter(query);
|
|
27
|
+
return {
|
|
28
|
+
sql: `${base.sql} AND component = ?`,
|
|
29
|
+
params: [...base.params, query.component],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function subjectFilter(query) {
|
|
33
|
+
const base = windowFilter(query);
|
|
34
|
+
return {
|
|
35
|
+
sql: `${base.sql} AND subject = ?`,
|
|
36
|
+
params: [...base.params, query.subject],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function tokenFilter(query) {
|
|
40
|
+
const base = windowFilter(query);
|
|
41
|
+
// Every recorded value, approved or not. Which of them shipped is decided by
|
|
42
|
+
// `journeyFrom` in `@variance-authority/server`, against the approvals table —
|
|
43
|
+
// a write cannot know, because acceptance is a decision made after it.
|
|
44
|
+
return {
|
|
45
|
+
sql: `${base.sql} AND token = ?`,
|
|
46
|
+
params: [...base.params, query.token],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function areaFilter(query) {
|
|
50
|
+
const clauses = ['subject = ?', 'component = ?'];
|
|
51
|
+
const params = [query.subject, query.component];
|
|
52
|
+
scopeInto(query, clauses, params);
|
|
53
|
+
if (query.band !== undefined) {
|
|
54
|
+
clauses.push('band = ?');
|
|
55
|
+
params.push(query.band);
|
|
56
|
+
}
|
|
57
|
+
return { sql: clauses.map((clause) => ` AND ${clause}`).join(''), params };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One row per live scope in the named subjects: the newest, and only the newest.
|
|
61
|
+
*
|
|
62
|
+
* Transcribed from `sqlite-queries.ts`, window function included. D1 is SQLite,
|
|
63
|
+
* so `ROW_NUMBER()` is available and the tie-break on `rowid` means the same
|
|
64
|
+
* thing — the later write wins when two rows share an instant, which happens
|
|
65
|
+
* whenever one run recorded two profiles from a single clock read.
|
|
66
|
+
*
|
|
67
|
+
* **No `LIMIT`, in either backend.** A trimmed answer here is not a lower bound
|
|
68
|
+
* that a reader can be warned about; it is a `previous` set with a hole in it, and
|
|
69
|
+
* the run then appends a change that did not happen. The request is what is
|
|
70
|
+
* bounded — `MAX_CURRENT_SUBJECTS` — and the HTTP edge refuses a longer one.
|
|
71
|
+
*/
|
|
72
|
+
export async function currentRows(db, query) {
|
|
73
|
+
const subjects = [...new Set(query.subjects)];
|
|
74
|
+
if (subjects.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const clauses = [];
|
|
77
|
+
const params = [];
|
|
78
|
+
scopeInto(query, clauses, params);
|
|
79
|
+
clauses.push(`subject IN (${subjects.map(() => '?').join(', ')})`);
|
|
80
|
+
params.push(...subjects);
|
|
81
|
+
const where = clauses.map((clause) => ` AND ${clause}`).join('');
|
|
82
|
+
const rows = await db
|
|
83
|
+
.prepare('SELECT * FROM (SELECT *, ROW_NUMBER() OVER (' +
|
|
84
|
+
'PARTITION BY project, subject, component, band, profile ' +
|
|
85
|
+
'ORDER BY at_ms DESC, rowid DESC) AS recency ' +
|
|
86
|
+
`FROM observations WHERE 1 = 1${where}) WHERE recency = 1`)
|
|
87
|
+
.bind(...params)
|
|
88
|
+
.all();
|
|
89
|
+
return rows.results.map(toObservation);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The newest recorded value per token, and only the newest.
|
|
93
|
+
*
|
|
94
|
+
* Transcribed from `sqlite-queries.ts`, window function and tie-break included.
|
|
95
|
+
* No `LIMIT`, for the reason `currentRows` has none: a token missing from the
|
|
96
|
+
* answer reads as one nobody has recorded, and the run then writes a value that
|
|
97
|
+
* did not move.
|
|
98
|
+
*/
|
|
99
|
+
export async function currentTokenRows(db, query) {
|
|
100
|
+
const clauses = [];
|
|
101
|
+
const params = [];
|
|
102
|
+
scopeInto(query, clauses, params);
|
|
103
|
+
const where = clauses.map((clause) => ` AND ${clause}`).join('');
|
|
104
|
+
const rows = await db
|
|
105
|
+
.prepare('SELECT * FROM (SELECT *, ROW_NUMBER() OVER (' +
|
|
106
|
+
'PARTITION BY project, token ORDER BY at_ms DESC, rowid DESC) AS recency ' +
|
|
107
|
+
`FROM token_values WHERE 1 = 1${where}) WHERE recency = 1`)
|
|
108
|
+
.bind(...params)
|
|
109
|
+
.all();
|
|
110
|
+
return rows.results.map(toTokenValue);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Scope to a project, or deliberately do not.
|
|
114
|
+
*
|
|
115
|
+
* An unscoped query on a shared deployment blends two projects' `Button` into one
|
|
116
|
+
* rate and nothing in the answer would show it. The choice belongs to the caller,
|
|
117
|
+
* so both are implemented and neither is a default.
|
|
118
|
+
*/
|
|
119
|
+
function scopeInto(query, clauses, params) {
|
|
120
|
+
if (query.project === undefined)
|
|
121
|
+
return;
|
|
122
|
+
clauses.push('project = ?');
|
|
123
|
+
params.push(query.project);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The newest rows that fit, returned oldest first, plus the count of the rest.
|
|
127
|
+
*
|
|
128
|
+
* Newest-first selection and chronological return, both deliberate and both
|
|
129
|
+
* transcribed: a limited drift question asks what happened lately, so the *old*
|
|
130
|
+
* end is what gets dropped; and every consumer reads better in the order events
|
|
131
|
+
* happened. What the limit excluded is counted with a second query rather than
|
|
132
|
+
* inferred from a `limit + 1` probe — "there are more" is not a number, and a
|
|
133
|
+
* lower bound whose distance from the truth is unknown is not worth printing.
|
|
134
|
+
*/
|
|
135
|
+
export async function slice(db, table, filter, limit, read) {
|
|
136
|
+
const where = `WHERE 1 = 1${filter.sql}`;
|
|
137
|
+
if (limit === undefined) {
|
|
138
|
+
const all = await db
|
|
139
|
+
.prepare(`SELECT * FROM ${table} ${where} ORDER BY at_ms ASC, rowid ASC`)
|
|
140
|
+
.bind(...filter.params)
|
|
141
|
+
.all();
|
|
142
|
+
return { rows: all.results.map(read), omitted: 0 };
|
|
143
|
+
}
|
|
144
|
+
const counted = await db
|
|
145
|
+
.prepare(`SELECT COUNT(*) AS total FROM ${table} ${where}`)
|
|
146
|
+
.bind(...filter.params)
|
|
147
|
+
.first();
|
|
148
|
+
const total = number(counted ?? {}, 'total', `a count of ${table}`);
|
|
149
|
+
const newest = await db
|
|
150
|
+
.prepare(`SELECT * FROM ${table} ${where} ORDER BY at_ms DESC, rowid DESC LIMIT ?`)
|
|
151
|
+
.bind(...filter.params, limit)
|
|
152
|
+
.all();
|
|
153
|
+
const rows = [...newest.results].reverse().map(read);
|
|
154
|
+
return { rows, omitted: Math.max(0, total - newest.results.length) };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reach, in one grouped query plus a correlated lookup outside the window.
|
|
158
|
+
*
|
|
159
|
+
* The correlated subquery is the point: `arrived` asks whether a subject's
|
|
160
|
+
* *first-ever* observation of this component falls inside the window, which the
|
|
161
|
+
* window's own rows cannot answer. Without it, every subject looks new in every
|
|
162
|
+
* window and the one answer a single run genuinely cannot produce degrades into a
|
|
163
|
+
* restatement of the subject list.
|
|
164
|
+
*/
|
|
165
|
+
export async function reachRows(db, query) {
|
|
166
|
+
const filter = componentFilter(query);
|
|
167
|
+
const where = `WHERE 1 = 1${filter.sql}`;
|
|
168
|
+
// Grouped by `(project, subject)` rather than by subject alone, so an unscoped
|
|
169
|
+
// query over two projects does not fuse two different subjects that happen to
|
|
170
|
+
// share a name. The names are de-duplicated below, which is the one place an
|
|
171
|
+
// unscoped question is visibly lossy.
|
|
172
|
+
const grouped = 'SELECT subject, MIN(at_ms) AS first_in_window, ' +
|
|
173
|
+
'(SELECT MIN(e.at_ms) FROM observations e WHERE e.project = observations.project ' +
|
|
174
|
+
'AND e.component = observations.component AND e.subject = observations.subject) AS first_ever ' +
|
|
175
|
+
`FROM observations ${where} ` +
|
|
176
|
+
'GROUP BY project, subject ORDER BY first_in_window ASC, subject ASC';
|
|
177
|
+
const listed = await (query.limit === undefined
|
|
178
|
+
? db.prepare(grouped).bind(...filter.params)
|
|
179
|
+
: db.prepare(`${grouped} LIMIT ?`).bind(...filter.params, query.limit)).all();
|
|
180
|
+
const total = query.limit === undefined
|
|
181
|
+
? listed.results.length
|
|
182
|
+
: number((await db
|
|
183
|
+
.prepare(`SELECT COUNT(*) AS total FROM (${grouped})`)
|
|
184
|
+
.bind(...filter.params)
|
|
185
|
+
.first()) ?? {}, 'total', 'a count of subjects');
|
|
186
|
+
const since = query.since === undefined ? undefined : instant(query.since, 'window `since`');
|
|
187
|
+
const subjects = [];
|
|
188
|
+
const arrived = [];
|
|
189
|
+
for (const row of listed.results) {
|
|
190
|
+
const subject = text(row, 'subject', 'a reach row');
|
|
191
|
+
if (!subjects.includes(subject))
|
|
192
|
+
subjects.push(subject);
|
|
193
|
+
const firstEver = number(row, 'first_ever', 'a reach row');
|
|
194
|
+
if ((since === undefined || firstEver >= since) && !arrived.includes(subject)) {
|
|
195
|
+
arrived.push(subject);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { subjects, arrived, omittedSubjects: Math.max(0, total - listed.results.length) };
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=history-queries.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-queries.js","sourceRoot":"","sources":["../src/history-queries.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Observation, TokenValue } from '@variance-authority/history';\nimport type {\n AreaQuery,\n BackendQuery,\n ComponentWindowQuery,\n ReachRows,\n Slice,\n SubjectsQuery,\n SubjectWindowQuery,\n TokenWindowQuery,\n WindowQuery,\n} from '@variance-authority/server';\nimport type { D1Like, D1Value } from './bindings.js';\nimport {\n instant,\n number,\n text,\n toObservation,\n toTokenValue,\n type Row,\n} from './history-rows.js';\n\n/**\n * The predicates, and the three reads whose shape is not a plain `SELECT *`.\n *\n * Split from [`history.ts`](./history.ts) for the reason the SQLite backend is\n * split the same way: none of this touches a connection's lifetime or a write.\n * Every function takes a binding and returns rows, so what is under test when\n * these are tested is the SQL — and the file next door stays what it says it is,\n * a transcription of the append path.\n *\n * Transcribed, filter for filter, from\n * [`server/sqlite-queries.ts`](../../server/src/sqlite-queries.ts). The seam\n * exists so that replacing the engine changes what a query *is* and never what a\n * row *means*; the way to honour that is for the two files to be boringly alike.\n */\n\ninterface Filter {\n readonly sql: string;\n readonly params: readonly D1Value[];\n}\n\n/**\n * A window as SQL, over the parsed instant rather than the stored text.\n *\n * ISO-8601 sorts lexically only while every timestamp shares one offset, and a\n * store fed by CI jobs in two regions does not. A bound that will not parse is\n * refused here rather than bound as `NaN`, which would reach SQLite as NULL,\n * compare false against every row, and return an empty answer that reads as\n * \"nothing changed\".\n */\nexport function windowFilter(query: WindowQuery): Filter {\n const clauses: string[] = [];\n const params: D1Value[] = [];\n\n scopeInto(query, clauses, params);\n\n if (query.since !== undefined) {\n clauses.push('at_ms >= ?');\n params.push(instant(query.since, 'window `since`'));\n }\n if (query.until !== undefined) {\n clauses.push('at_ms <= ?');\n params.push(instant(query.until, 'window `until`'));\n }\n\n return { sql: clauses.map((clause) => ` AND ${clause}`).join(''), params };\n}\n\nexport function componentFilter(query: ComponentWindowQuery): Filter {\n const base = windowFilter(query);\n return {\n sql: `${base.sql} AND component = ?`,\n params: [...base.params, query.component],\n };\n}\n\nexport function subjectFilter(query: SubjectWindowQuery): Filter {\n const base = windowFilter(query);\n return {\n sql: `${base.sql} AND subject = ?`,\n params: [...base.params, query.subject],\n };\n}\n\nexport function tokenFilter(query: TokenWindowQuery): Filter {\n const base = windowFilter(query);\n // Every recorded value, approved or not. Which of them shipped is decided by\n // `journeyFrom` in `@variance-authority/server`, against the approvals table —\n // a write cannot know, because acceptance is a decision made after it.\n return {\n sql: `${base.sql} AND token = ?`,\n params: [...base.params, query.token],\n };\n}\n\nexport function areaFilter(query: AreaQuery): Filter {\n const clauses = ['subject = ?', 'component = ?'];\n const params: D1Value[] = [query.subject, query.component];\n\n scopeInto(query, clauses, params);\n\n if (query.band !== undefined) {\n clauses.push('band = ?');\n params.push(query.band);\n }\n\n return { sql: clauses.map((clause) => ` AND ${clause}`).join(''), params };\n}\n\n/**\n * One row per live scope in the named subjects: the newest, and only the newest.\n *\n * Transcribed from `sqlite-queries.ts`, window function included. D1 is SQLite,\n * so `ROW_NUMBER()` is available and the tie-break on `rowid` means the same\n * thing — the later write wins when two rows share an instant, which happens\n * whenever one run recorded two profiles from a single clock read.\n *\n * **No `LIMIT`, in either backend.** A trimmed answer here is not a lower bound\n * that a reader can be warned about; it is a `previous` set with a hole in it, and\n * the run then appends a change that did not happen. The request is what is\n * bounded — `MAX_CURRENT_SUBJECTS` — and the HTTP edge refuses a longer one.\n */\nexport async function currentRows(db: D1Like, query: SubjectsQuery): Promise<readonly Observation[]> {\n const subjects = [...new Set(query.subjects)];\n if (subjects.length === 0) return [];\n\n const clauses: string[] = [];\n const params: D1Value[] = [];\n scopeInto(query, clauses, params);\n\n clauses.push(`subject IN (${subjects.map(() => '?').join(', ')})`);\n params.push(...subjects);\n\n const where = clauses.map((clause) => ` AND ${clause}`).join('');\n\n const rows = await db\n .prepare(\n 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (' +\n 'PARTITION BY project, subject, component, band, profile ' +\n 'ORDER BY at_ms DESC, rowid DESC) AS recency ' +\n `FROM observations WHERE 1 = 1${where}) WHERE recency = 1`,\n )\n .bind(...params)\n .all<Row>();\n\n return rows.results.map(toObservation);\n}\n\n/**\n * The newest recorded value per token, and only the newest.\n *\n * Transcribed from `sqlite-queries.ts`, window function and tie-break included.\n * No `LIMIT`, for the reason `currentRows` has none: a token missing from the\n * answer reads as one nobody has recorded, and the run then writes a value that\n * did not move.\n */\nexport async function currentTokenRows(\n db: D1Like,\n query: BackendQuery,\n): Promise<readonly TokenValue[]> {\n const clauses: string[] = [];\n const params: D1Value[] = [];\n scopeInto(query, clauses, params);\n\n const where = clauses.map((clause) => ` AND ${clause}`).join('');\n\n const rows = await db\n .prepare(\n 'SELECT * FROM (SELECT *, ROW_NUMBER() OVER (' +\n 'PARTITION BY project, token ORDER BY at_ms DESC, rowid DESC) AS recency ' +\n `FROM token_values WHERE 1 = 1${where}) WHERE recency = 1`,\n )\n .bind(...params)\n .all<Row>();\n\n return rows.results.map(toTokenValue);\n}\n\n/**\n * Scope to a project, or deliberately do not.\n *\n * An unscoped query on a shared deployment blends two projects' `Button` into one\n * rate and nothing in the answer would show it. The choice belongs to the caller,\n * so both are implemented and neither is a default.\n */\nfunction scopeInto(query: BackendQuery, clauses: string[], params: D1Value[]): void {\n if (query.project === undefined) return;\n clauses.push('project = ?');\n params.push(query.project);\n}\n\n/**\n * The newest rows that fit, returned oldest first, plus the count of the rest.\n *\n * Newest-first selection and chronological return, both deliberate and both\n * transcribed: a limited drift question asks what happened lately, so the *old*\n * end is what gets dropped; and every consumer reads better in the order events\n * happened. What the limit excluded is counted with a second query rather than\n * inferred from a `limit + 1` probe — \"there are more\" is not a number, and a\n * lower bound whose distance from the truth is unknown is not worth printing.\n */\nexport async function slice<T>(\n db: D1Like,\n table: string,\n filter: Filter,\n limit: number | undefined,\n read: (row: Row) => T,\n): Promise<Slice<T>> {\n const where = `WHERE 1 = 1${filter.sql}`;\n\n if (limit === undefined) {\n const all = await db\n .prepare(`SELECT * FROM ${table} ${where} ORDER BY at_ms ASC, rowid ASC`)\n .bind(...filter.params)\n .all<Row>();\n return { rows: all.results.map(read), omitted: 0 };\n }\n\n const counted = await db\n .prepare(`SELECT COUNT(*) AS total FROM ${table} ${where}`)\n .bind(...filter.params)\n .first<Row>();\n const total = number(counted ?? {}, 'total', `a count of ${table}`);\n\n const newest = await db\n .prepare(`SELECT * FROM ${table} ${where} ORDER BY at_ms DESC, rowid DESC LIMIT ?`)\n .bind(...filter.params, limit)\n .all<Row>();\n\n const rows = [...newest.results].reverse().map(read);\n return { rows, omitted: Math.max(0, total - newest.results.length) };\n}\n\n/**\n * Reach, in one grouped query plus a correlated lookup outside the window.\n *\n * The correlated subquery is the point: `arrived` asks whether a subject's\n * *first-ever* observation of this component falls inside the window, which the\n * window's own rows cannot answer. Without it, every subject looks new in every\n * window and the one answer a single run genuinely cannot produce degrades into a\n * restatement of the subject list.\n */\nexport async function reachRows(db: D1Like, query: ComponentWindowQuery): Promise<ReachRows> {\n const filter = componentFilter(query);\n const where = `WHERE 1 = 1${filter.sql}`;\n\n // Grouped by `(project, subject)` rather than by subject alone, so an unscoped\n // query over two projects does not fuse two different subjects that happen to\n // share a name. The names are de-duplicated below, which is the one place an\n // unscoped question is visibly lossy.\n const grouped =\n 'SELECT subject, MIN(at_ms) AS first_in_window, ' +\n '(SELECT MIN(e.at_ms) FROM observations e WHERE e.project = observations.project ' +\n 'AND e.component = observations.component AND e.subject = observations.subject) AS first_ever ' +\n `FROM observations ${where} ` +\n 'GROUP BY project, subject ORDER BY first_in_window ASC, subject ASC';\n\n const listed = await (query.limit === undefined\n ? db.prepare(grouped).bind(...filter.params)\n : db.prepare(`${grouped} LIMIT ?`).bind(...filter.params, query.limit)\n ).all<Row>();\n\n const total =\n query.limit === undefined\n ? listed.results.length\n : number(\n (await db\n .prepare(`SELECT COUNT(*) AS total FROM (${grouped})`)\n .bind(...filter.params)\n .first<Row>()) ?? {},\n 'total',\n 'a count of subjects',\n );\n\n const since = query.since === undefined ? undefined : instant(query.since, 'window `since`');\n\n const subjects: string[] = [];\n const arrived: string[] = [];\n for (const row of listed.results) {\n const subject = text(row, 'subject', 'a reach row');\n if (!subjects.includes(subject)) subjects.push(subject);\n\n const firstEver = number(row, 'first_ever', 'a reach row');\n if ((since === undefined || firstEver >= since) && !arrived.includes(subject)) {\n arrived.push(subject);\n }\n }\n\n return { subjects, arrived, omittedSubjects: Math.max(0, total - listed.results.length) };\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Approval, type Instability, type Observation, type RunRecord, type TokenValue } from '@variance-authority/history';
|
|
2
|
+
/**
|
|
3
|
+
* Reading a row back is validation, not a cast.
|
|
4
|
+
*
|
|
5
|
+
* SQLite is typed per value rather than per column, and `STRICT` only constrains
|
|
6
|
+
* what this build writes. A row written by a future version, or by a person at a
|
|
7
|
+
* `wrangler d1 execute` prompt, arrives here as whatever it is; casting it would
|
|
8
|
+
* let a number become a component name and a NULL become the string "null" three
|
|
9
|
+
* layers later.
|
|
10
|
+
*
|
|
11
|
+
* That argument is the whole of this file, and it is why it is a file: every
|
|
12
|
+
* reader below is one refusal, and the same handful of them is used by the append
|
|
13
|
+
* path, the filters, the slices and the reach query alike. `instant` sits with
|
|
14
|
+
* them because it is the same refusal applied to a bound rather than to a column.
|
|
15
|
+
* Nothing here knows any SQL, and [`history.ts`](./history.ts) knows nothing
|
|
16
|
+
* about what a stored value is allowed to be.
|
|
17
|
+
*/
|
|
18
|
+
export type Row = Record<string, unknown>;
|
|
19
|
+
/**
|
|
20
|
+
* An instant as a comparable number.
|
|
21
|
+
*
|
|
22
|
+
* Refuses rather than defaults. A row whose `at` cannot be parsed would land with
|
|
23
|
+
* a NULL ordering key, sort ahead of or behind everything depending on the query,
|
|
24
|
+
* and turn `12px → 20px` into `20px → 12px` — a confident sentence that is exactly
|
|
25
|
+
* backwards.
|
|
26
|
+
*/
|
|
27
|
+
export declare function instant(at: string, what: string): number;
|
|
28
|
+
export declare function text(row: Row, column: string, what: string): string;
|
|
29
|
+
export declare function number(row: Row, column: string, what: string): number;
|
|
30
|
+
export declare function toObservation(row: Row): Observation;
|
|
31
|
+
export declare function toRunRecord(row: Row): RunRecord;
|
|
32
|
+
/**
|
|
33
|
+
* One occurrence of a subject failing to read the same way twice.
|
|
34
|
+
*
|
|
35
|
+
* Transcribed from `server/sqlite-rows.ts`, nullability included: a component
|
|
36
|
+
* that could not be named comes back absent rather than as an empty string,
|
|
37
|
+
* because empty would read as a component whose name is nothing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function toInstability(row: Row): Instability;
|
|
40
|
+
export declare function toApproval(row: Row): Approval;
|
|
41
|
+
export declare function toTokenValue(row: Row): TokenValue;
|
|
42
|
+
//# sourceMappingURL=history-rows.d.ts.map
|