@variance-authority/core 0.9.0 → 0.10.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +46 -20
  3. package/dist/attribute/boundary.d.ts +21 -14
  4. package/dist/attribute/boundary.js +23 -29
  5. package/dist/attribute/boundary.js.map +1 -1
  6. package/dist/attribute/component-hash.d.ts +21 -0
  7. package/dist/attribute/component-hash.js +48 -3
  8. package/dist/attribute/component-hash.js.map +1 -1
  9. package/dist/attribute/index.d.ts +3 -3
  10. package/dist/attribute/index.js +1 -1
  11. package/dist/attribute/index.js.map +1 -1
  12. package/dist/attribute/lexicon.d.ts +37 -0
  13. package/dist/attribute/lexicon.js +40 -12
  14. package/dist/attribute/lexicon.js.map +1 -1
  15. package/dist/attribute/rename.d.ts +18 -0
  16. package/dist/attribute/rename.js +24 -0
  17. package/dist/attribute/rename.js.map +1 -0
  18. package/dist/compare/cascade.d.ts +2 -0
  19. package/dist/compare/cascade.js +5 -1
  20. package/dist/compare/cascade.js.map +1 -1
  21. package/dist/format/hash.d.ts +20 -0
  22. package/dist/format/hash.js +27 -1
  23. package/dist/format/hash.js.map +1 -1
  24. package/dist/format/index.d.ts +1 -1
  25. package/dist/format/index.js +1 -1
  26. package/dist/format/index.js.map +1 -1
  27. package/dist/format/snapshot.d.ts +15 -0
  28. package/dist/format/snapshot.js.map +1 -1
  29. package/dist/share/http-line.d.ts +30 -0
  30. package/dist/share/http-line.js +164 -0
  31. package/dist/share/http-line.js.map +1 -0
  32. package/dist/share/index.d.ts +9 -126
  33. package/dist/share/index.js +9 -168
  34. package/dist/share/index.js.map +1 -1
  35. package/dist/share/line.d.ts +153 -0
  36. package/dist/share/line.js +184 -0
  37. package/dist/share/line.js.map +1 -0
  38. package/dist/share/publish.d.ts +79 -0
  39. package/dist/share/publish.js +231 -0
  40. package/dist/share/publish.js.map +1 -0
  41. package/package.json +1 -1
@@ -10,181 +10,22 @@
10
10
  * So the second machine should not derive it again. A pull-request runner wants
11
11
  * what mainline already knows, and mainline already knew it an hour ago on a
12
12
  * runner that has since been destroyed. This module is the seam between those
13
- * two: a place bytes can be left under a key, and a place they can be looked
14
- * for, with nothing in between that knows what the bytes are.
15
- *
16
- * ## The rule, inherited
17
- *
18
- * > **A `SharedCache` never throws.**
19
- *
20
- * `get` answers `null` for a miss, an outage, a permission error, a truncated
21
- * body and a response nobody can parse. `put` resolves whether or not anything
22
- * was written. This is the same rule
23
- * [`RenderCache`](../../../raster/src/store.ts) is defined by, and it is safe
24
- * here for the same reason: everything a share holds is derivable from the tree
25
- * it was derived at. A miss costs the derivation, which is the answer that was
26
- * always available.
27
- *
28
- * What that costs, stated rather than hidden: a cache that is failing looks
29
- * exactly like a cache that is cold, so a broken share makes CI slow and never
30
- * makes it red. An operator watching the wall clock has to go looking. The
31
- * alternative — failing a build because a bucket answered 503 — is worse.
32
- *
33
- * ## Why a key is a commit
34
- *
35
- * Everything shared here is derived, so the only question a reader has is *of
36
- * what*, and the answer is a tree. A commit names one; a branch names whichever
37
- * one it points at this second, and two runners resolving `main` four minutes
38
- * apart would silently compare against different suites.
39
- *
40
- * Mainline moves while a branch is open, though, which is why the lookup is
41
- * {@link firstShared} over a lineage rather than a single `get`: the runner asks
42
- * for the commits its branch actually descends from, newest first, and takes the
43
- * first one anybody published. What it gets back says which commit it is, so a
44
- * reader is never in doubt about what it is holding.
13
+ * two: a `LineCell` holds the latest of what one mainline or one branch
14
+ * published, `publishLine` replaces it, and `readLine` reads it back
15
+ * with the reason when it cannot.
45
16
  *
46
17
  * ## What this is not
47
18
  *
48
19
  * Not a baseline store. A baseline is the thing a comparison is *against*, it
49
20
  * cannot be re-derived, and losing one silently is how a suite records whatever
50
21
  * happened to be on screen — which is why
51
- * [`RasterStore`](../../../raster/src/store.ts) throws on every failure and this
52
- * throws on none. The two are configured separately because they are answers to
22
+ * [`RasterStore`](../../../raster/src/store.ts) throws on every failure. A line
23
+ * holds what can be derived again from the commit it names, so a miss costs the
24
+ * derivation. The two are configured separately because they are answers to
53
25
  * different questions, and a share is disposable in a way a baseline may never
54
26
  * be.
55
27
  */
56
- /**
57
- * Hold an implementation to the rule.
58
- *
59
- * Backends are built out of a disk, a socket or a bucket, every one of which
60
- * throws, and every one of them would otherwise have to remember not to.
61
- * Wrapping at construction makes the rule a property of the value rather than a
62
- * promise in a comment.
63
- */
64
- export function neverFails(cache) {
65
- return {
66
- async get(key) {
67
- try {
68
- return await cache.get(key);
69
- }
70
- catch {
71
- // A miss and a failure are the same instruction: derive it.
72
- return null;
73
- }
74
- },
75
- async put(key, bytes) {
76
- try {
77
- await cache.put(key, bytes);
78
- }
79
- catch {
80
- // Nothing to report and nothing to retry. The next run publishes again.
81
- }
82
- },
83
- };
84
- }
85
- /**
86
- * The key an artifact is published under.
87
- *
88
- * `project` is a namespace and not a secret: two suites in one monorepo share a
89
- * bucket and must not share a key. `artifact` names the format, so a reader that
90
- * does not understand version 2 of something asks for a key version 2 was never
91
- * written to rather than parsing bytes it will reject.
92
- */
93
- export function shareKey(parts) {
94
- return `${segment(parts.project)}/${segment(parts.artifact)}/${segment(parts.commit)}.bin`;
95
- }
96
- /**
97
- * The newest published artifact among commits this tree descends from.
98
- *
99
- * Newest-first because a nearer ancestor is a better description of the same
100
- * suite, and `behind` is returned rather than discarded because it is the one
101
- * number that tells an operator their publishing is failing: a share that is
102
- * always forty commits behind is a share nothing is writing to, and it is
103
- * otherwise indistinguishable from a healthy one.
104
- *
105
- * Stops at the first hit rather than collecting all of them. There is no merge
106
- * across generations to perform — an evaluation describes one tree — and asking
107
- * a network for forty keys that will be discarded is the cost this exists to
108
- * avoid.
109
- */
110
- export async function firstShared(cache, lineage, of) {
111
- for (let behind = 0; behind < lineage.length; behind += 1) {
112
- const commit = lineage[behind];
113
- const bytes = await cache.get(of(commit));
114
- if (bytes !== null)
115
- return { commit, behind, bytes };
116
- }
117
- return null;
118
- }
119
- /**
120
- * A cache in this process, for tests and for a run that shares with itself.
121
- *
122
- * Kept here rather than in a test helper because it is also the honest answer
123
- * to "share, but I have nowhere to put it": the seam is exercised, the code
124
- * path is the real one, and nothing survives the process.
125
- */
126
- export function memoryShare() {
127
- const held = new Map();
128
- return {
129
- async get(key) {
130
- return held.get(key) ?? null;
131
- },
132
- async put(key, bytes) {
133
- held.set(key, bytes.slice());
134
- },
135
- };
136
- }
137
- /**
138
- * A share over HTTP: `GET` to read, `PUT` to write, the key as the path.
139
- *
140
- * One backend rather than four because a bucket, a signed URL, a static host
141
- * and a tribunal deployment differ in exactly two ways — the base and the
142
- * headers — and every one of them speaks this. S3 with `PUT` object access is
143
- * this with an `Authorization` header; a presigned base is this with none; a
144
- * deployment is this with a token. Writing three clients would have produced
145
- * three sets of error handling for an object whose entire error handling is
146
- * "return null".
147
- *
148
- * Nothing here signs anything. Credentials that must be computed per request —
149
- * SigV4 against a raw bucket — belong to whatever already holds the operator's
150
- * credentials, and the shape that reaches this is a base URL it produced. That
151
- * keeps a secret out of this package and out of the config file it would
152
- * otherwise have to be written in.
153
- */
154
- export function httpShare(options) {
155
- const base = options.endpoint.replace(/\/+$/, '');
156
- const headers = options.headers ?? {};
157
- return {
158
- async get(key) {
159
- const response = await fetch(`${base}/${key}`, { headers });
160
- // A 404 is the common answer and not an event. Every other refusal is
161
- // read the same way, because the caller's response to all of them is one
162
- // response, and a cache that distinguished them would be inviting a
163
- // caller to act on the difference.
164
- if (!response.ok)
165
- return null;
166
- return new Uint8Array(await response.arrayBuffer());
167
- },
168
- async put(key, bytes) {
169
- await fetch(`${base}/${key}`, {
170
- method: options.method ?? 'PUT',
171
- headers: { 'content-type': 'application/octet-stream', ...headers },
172
- body: bytes,
173
- });
174
- },
175
- };
176
- }
177
- /**
178
- * One path segment, with everything that is not a key character removed.
179
- *
180
- * A project name comes from a config file and a commit from `git`, and both end
181
- * up in a URL and on a filesystem. Escaping would be reversible and is not
182
- * wanted: a name that needs escaping is a name whose two spellings should not
183
- * address two different entries, so the mapping is deliberately many-to-one and
184
- * the cost of a collision is a cache miss.
185
- */
186
- function segment(value) {
187
- const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
188
- return cleaned === '' ? 'unnamed' : cleaned;
189
- }
28
+ export * from './line.js';
29
+ export * from './publish.js';
30
+ export * from './http-line.js';
190
31
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/share/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AA+BH;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,KAAkB;IAC3C,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG;YACX,IAAI,CAAC;gBACH,OAAO,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC9B,CAAC;YAAC,MAAM,CAAC;gBACP,4DAA4D;gBAC5D,OAAO,IAAI,CAAC;YACd,CAAC;QACH,CAAC;QACD,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK;YAClB,IAAI,CAAC;gBACH,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC9B,CAAC;YAAC,MAAM,CAAC;gBACP,wEAAwE;YAC1E,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CAAC,KAIxB;IACC,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC;AAC7F,CAAC;AAWD;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,KAAkB,EAClB,OAA0B,EAC1B,EAA8B;IAE9B,KAAK,IAAI,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,EAAE,CAAC;QAC1D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAE,CAAC;QAChC,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;QAC1C,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IACvD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW;IACzB,MAAM,IAAI,GAAG,IAAI,GAAG,EAAsB,CAAC;IAC3C,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG;YACX,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;QAC/B,CAAC;QACD,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK;YAClB,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;QAC/B,CAAC;KACF,CAAC;AACJ,CAAC;AAYD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CAAC,OAAyB;IACjD,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC;IACtC,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,GAAG;YACX,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,IAAI,GAAG,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;YAC5D,sEAAsE;YACtE,yEAAyE;YACzE,oEAAoE;YACpE,mCAAmC;YACnC,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,OAAO,IAAI,CAAC;YAC9B,OAAO,IAAI,UAAU,CAAC,MAAM,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC;QACtD,CAAC;QACD,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK;YAClB,MAAM,KAAK,CAAC,GAAG,IAAI,IAAI,GAAG,EAAE,EAAE;gBAC5B,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,KAAK;gBAC/B,OAAO,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE,GAAG,OAAO,EAAE;gBACnE,IAAI,EAAE,KAAK;aACZ,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,OAAO,CAAC,KAAa;IAC5B,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,mBAAmB,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAC;IACtF,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC;AAC9C,CAAC","sourcesContent":["/**\n * `@variance-authority/core/share` — one machine's evaluation, reachable from another.\n *\n * A run derives a great deal that is not about the run: what the suite is made\n * of, which subjects exist, every name each one carries, which files declare\n * which components. All of it is a fact about a *commit*, and all of it is\n * expensive — a source scan, a browser, a composition pass — while being\n * identical on every machine that starts from the same tree.\n *\n * So the second machine should not derive it again. A pull-request runner wants\n * what mainline already knows, and mainline already knew it an hour ago on a\n * runner that has since been destroyed. This module is the seam between those\n * two: a place bytes can be left under a key, and a place they can be looked\n * for, with nothing in between that knows what the bytes are.\n *\n * ## The rule, inherited\n *\n * > **A `SharedCache` never throws.**\n *\n * `get` answers `null` for a miss, an outage, a permission error, a truncated\n * body and a response nobody can parse. `put` resolves whether or not anything\n * was written. This is the same rule\n * [`RenderCache`](../../../raster/src/store.ts) is defined by, and it is safe\n * here for the same reason: everything a share holds is derivable from the tree\n * it was derived at. A miss costs the derivation, which is the answer that was\n * always available.\n *\n * What that costs, stated rather than hidden: a cache that is failing looks\n * exactly like a cache that is cold, so a broken share makes CI slow and never\n * makes it red. An operator watching the wall clock has to go looking. The\n * alternative — failing a build because a bucket answered 503 — is worse.\n *\n * ## Why a key is a commit\n *\n * Everything shared here is derived, so the only question a reader has is *of\n * what*, and the answer is a tree. A commit names one; a branch names whichever\n * one it points at this second, and two runners resolving `main` four minutes\n * apart would silently compare against different suites.\n *\n * Mainline moves while a branch is open, though, which is why the lookup is\n * {@link firstShared} over a lineage rather than a single `get`: the runner asks\n * for the commits its branch actually descends from, newest first, and takes the\n * first one anybody published. What it gets back says which commit it is, so a\n * reader is never in doubt about what it is holding.\n *\n * ## What this is not\n *\n * Not a baseline store. A baseline is the thing a comparison is *against*, it\n * cannot be re-derived, and losing one silently is how a suite records whatever\n * happened to be on screen — which is why\n * [`RasterStore`](../../../raster/src/store.ts) throws on every failure and this\n * throws on none. The two are configured separately because they are answers to\n * different questions, and a share is disposable in a way a baseline may never\n * be.\n */\n\n// Declared rather than imported, as elsewhere in this package: `core` takes no\n// platform's types, and `fetch` is present in every runtime it targets. Only the\n// three members used are named, so a runtime with a narrower one still fits.\ndeclare const fetch: (\n url: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n body?: Uint8Array;\n },\n) => Promise<{\n readonly ok: boolean;\n readonly status: number;\n arrayBuffer(): Promise<ArrayBuffer>;\n}>;\n\n/**\n * Bytes under a key, on terms where losing them is free.\n *\n * Deliberately the smallest interface that can carry an evaluation: no listing,\n * no deletion, no expiry. A backend that can do more is welcome to; nothing\n * here may depend on it, because the set of things every one of a directory, an\n * action cache, a bucket and a deployment can do is this.\n */\nexport interface SharedCache {\n get(key: string): Promise<Uint8Array | null>;\n put(key: string, bytes: Uint8Array): Promise<void>;\n}\n\n/**\n * Hold an implementation to the rule.\n *\n * Backends are built out of a disk, a socket or a bucket, every one of which\n * throws, and every one of them would otherwise have to remember not to.\n * Wrapping at construction makes the rule a property of the value rather than a\n * promise in a comment.\n */\nexport function neverFails(cache: SharedCache): SharedCache {\n return {\n async get(key): Promise<Uint8Array | null> {\n try {\n return await cache.get(key);\n } catch {\n // A miss and a failure are the same instruction: derive it.\n return null;\n }\n },\n async put(key, bytes): Promise<void> {\n try {\n await cache.put(key, bytes);\n } catch {\n // Nothing to report and nothing to retry. The next run publishes again.\n }\n },\n };\n}\n\n/**\n * The key an artifact is published under.\n *\n * `project` is a namespace and not a secret: two suites in one monorepo share a\n * bucket and must not share a key. `artifact` names the format, so a reader that\n * does not understand version 2 of something asks for a key version 2 was never\n * written to rather than parsing bytes it will reject.\n */\nexport function shareKey(parts: {\n readonly project: string;\n readonly artifact: string;\n readonly commit: string;\n}): string {\n return `${segment(parts.project)}/${segment(parts.artifact)}/${segment(parts.commit)}.bin`;\n}\n\n/** What a lookup over a lineage found, and where. */\nexport interface SharedHit {\n /** The commit the bytes were published at — never the one that was asked for first. */\n readonly commit: string;\n /** How many commits back that was, zero being the newest candidate offered. */\n readonly behind: number;\n readonly bytes: Uint8Array;\n}\n\n/**\n * The newest published artifact among commits this tree descends from.\n *\n * Newest-first because a nearer ancestor is a better description of the same\n * suite, and `behind` is returned rather than discarded because it is the one\n * number that tells an operator their publishing is failing: a share that is\n * always forty commits behind is a share nothing is writing to, and it is\n * otherwise indistinguishable from a healthy one.\n *\n * Stops at the first hit rather than collecting all of them. There is no merge\n * across generations to perform — an evaluation describes one tree — and asking\n * a network for forty keys that will be discarded is the cost this exists to\n * avoid.\n */\nexport async function firstShared(\n cache: SharedCache,\n lineage: readonly string[],\n of: (commit: string) => string,\n): Promise<SharedHit | null> {\n for (let behind = 0; behind < lineage.length; behind += 1) {\n const commit = lineage[behind]!;\n const bytes = await cache.get(of(commit));\n if (bytes !== null) return { commit, behind, bytes };\n }\n return null;\n}\n\n/**\n * A cache in this process, for tests and for a run that shares with itself.\n *\n * Kept here rather than in a test helper because it is also the honest answer\n * to \"share, but I have nowhere to put it\": the seam is exercised, the code\n * path is the real one, and nothing survives the process.\n */\nexport function memoryShare(): SharedCache {\n const held = new Map<string, Uint8Array>();\n return {\n async get(key) {\n return held.get(key) ?? null;\n },\n async put(key, bytes) {\n held.set(key, bytes.slice());\n },\n };\n}\n\n/** What an HTTP share needs to reach its endpoint. */\nexport interface HttpShareOptions {\n /** Base URL. A key is appended to it, with exactly one `/` between. */\n readonly endpoint: string;\n /** Sent on every request. A bucket wants `Authorization`; a signed URL wants nothing. */\n readonly headers?: Readonly<Record<string, string>>;\n /** The verb a write uses. `PUT` for a bucket, `POST` for a deployment that routes on it. */\n readonly method?: 'PUT' | 'POST';\n}\n\n/**\n * A share over HTTP: `GET` to read, `PUT` to write, the key as the path.\n *\n * One backend rather than four because a bucket, a signed URL, a static host\n * and a tribunal deployment differ in exactly two ways — the base and the\n * headers — and every one of them speaks this. S3 with `PUT` object access is\n * this with an `Authorization` header; a presigned base is this with none; a\n * deployment is this with a token. Writing three clients would have produced\n * three sets of error handling for an object whose entire error handling is\n * \"return null\".\n *\n * Nothing here signs anything. Credentials that must be computed per request —\n * SigV4 against a raw bucket — belong to whatever already holds the operator's\n * credentials, and the shape that reaches this is a base URL it produced. That\n * keeps a secret out of this package and out of the config file it would\n * otherwise have to be written in.\n */\nexport function httpShare(options: HttpShareOptions): SharedCache {\n const base = options.endpoint.replace(/\\/+$/, '');\n const headers = options.headers ?? {};\n return {\n async get(key) {\n const response = await fetch(`${base}/${key}`, { headers });\n // A 404 is the common answer and not an event. Every other refusal is\n // read the same way, because the caller's response to all of them is one\n // response, and a cache that distinguished them would be inviting a\n // caller to act on the difference.\n if (!response.ok) return null;\n return new Uint8Array(await response.arrayBuffer());\n },\n async put(key, bytes) {\n await fetch(`${base}/${key}`, {\n method: options.method ?? 'PUT',\n headers: { 'content-type': 'application/octet-stream', ...headers },\n body: bytes,\n });\n },\n };\n}\n\n/**\n * One path segment, with everything that is not a key character removed.\n *\n * A project name comes from a config file and a commit from `git`, and both end\n * up in a URL and on a filesystem. Escaping would be reversible and is not\n * wanted: a name that needs escaping is a name whose two spellings should not\n * address two different entries, so the mapping is deliberately many-to-one and\n * the cost of a collision is a cache miss.\n */\nfunction segment(value: string): string {\n const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');\n return cleaned === '' ? 'unnamed' : cleaned;\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/share/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC","sourcesContent":["/**\n * `@variance-authority/core/share` — one machine's evaluation, reachable from another.\n *\n * A run derives a great deal that is not about the run: what the suite is made\n * of, which subjects exist, every name each one carries, which files declare\n * which components. All of it is a fact about a *commit*, and all of it is\n * expensive — a source scan, a browser, a composition pass — while being\n * identical on every machine that starts from the same tree.\n *\n * So the second machine should not derive it again. A pull-request runner wants\n * what mainline already knows, and mainline already knew it an hour ago on a\n * runner that has since been destroyed. This module is the seam between those\n * two: a `LineCell` holds the latest of what one mainline or one branch\n * published, `publishLine` replaces it, and `readLine` reads it back\n * with the reason when it cannot.\n *\n * ## What this is not\n *\n * Not a baseline store. A baseline is the thing a comparison is *against*, it\n * cannot be re-derived, and losing one silently is how a suite records whatever\n * happened to be on screen — which is why\n * [`RasterStore`](../../../raster/src/store.ts) throws on every failure. A line\n * holds what can be derived again from the commit it names, so a miss costs the\n * derivation. The two are configured separately because they are answers to\n * different questions, and a share is disposable in a way a baseline may never\n * be.\n */\n\nexport * from './line.js';\nexport * from './publish.js';\nexport * from './http-line.js';\n"]}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * A line: the latest of what CI derived on one mainline or one branch.
3
+ *
4
+ * A line answers the question a checkout actually has — *what is mainline now*
5
+ * — by holding one version and replacing it, rather than keeping one artifact
6
+ * per commit for a reader to walk history in search of. Work is branched off a
7
+ * mainline and brought up to date with it before it merges, so the latest is
8
+ * the right answer, and the distance from a checkout to it is the instruction
9
+ * when it is not.
10
+ *
11
+ * A line holds **entries**, each a versioned artifact derived at one commit: the
12
+ * run report, one record per suite, the suite index. They are entries rather
13
+ * than one bundle because they arrive from different jobs, and a publish replaces
14
+ * only what it carries. It also holds the **images** its entries name, by digest,
15
+ * because an image does not compress and costs nothing to keep once.
16
+ *
17
+ * Nothing here collapses a failure into a miss. A reader has to be told whether
18
+ * nothing was published, it lacks the credentials, the store could not be
19
+ * reached, or a newer writer left a format it cannot read, because each of those
20
+ * asks for a different action.
21
+ */
22
+ /** One line a share holds the latest of. */
23
+ export interface ShareLine {
24
+ readonly kind: 'mainline' | 'branch';
25
+ /** The branch name as git spells it, `release/2.0` included. */
26
+ readonly name: string;
27
+ }
28
+ /** An artifact offered to a line. */
29
+ export interface ShareEntry {
30
+ /** `<family>-v<version>`, optionally `/<qualifier>`: `report-v1`, `suite-v1/web`. */
31
+ readonly name: string;
32
+ /** The commit it was derived at. For a pull request, the merge CI ran on. */
33
+ readonly commit: string;
34
+ /** The commit a pull request pointed at, when `commit` is the merge made for it. */
35
+ readonly head?: string;
36
+ /** Digests of the images this entry names, which the line keeps while it keeps the entry. */
37
+ readonly images?: readonly string[];
38
+ readonly bytes: Uint8Array;
39
+ }
40
+ /** An entry as the line holds it: what it is, where its bytes are, and nothing else. */
41
+ export interface HeldEntry {
42
+ readonly name: string;
43
+ readonly commit: string;
44
+ readonly head?: string;
45
+ readonly images?: readonly string[];
46
+ readonly digest: string;
47
+ readonly size: number;
48
+ }
49
+ /** What a line holds now. */
50
+ export interface LineManifest {
51
+ readonly format: 1;
52
+ /** Sorted by name, in code-unit order. */
53
+ readonly entries: readonly HeldEntry[];
54
+ }
55
+ /**
56
+ * Why a line could not answer.
57
+ *
58
+ * - `absent`: nothing was published under that name.
59
+ * - `newer`: something was, in a format this reader does not know.
60
+ * - `refused`: the store answered, and said no.
61
+ * - `unreachable`: the store did not answer, or not in time.
62
+ * - `unreadable`: the store answered with bytes that are not what they claim.
63
+ */
64
+ export type ShareMiss = {
65
+ readonly kind: 'absent';
66
+ } | {
67
+ readonly kind: 'newer';
68
+ readonly names: readonly string[];
69
+ } | {
70
+ readonly kind: 'refused';
71
+ readonly detail: string;
72
+ } | {
73
+ readonly kind: 'unreachable';
74
+ readonly detail: string;
75
+ } | {
76
+ readonly kind: 'unreadable';
77
+ readonly detail: string;
78
+ };
79
+ /** A path inside a line: an entry's bytes or an image, both by digest. */
80
+ export type BlobPath = `entries/${string}` | `images/${string}`;
81
+ /**
82
+ * The one thing a backend has to provide: a manifest that is replaced only if
83
+ * nobody replaced it first, and the blobs it names.
84
+ *
85
+ * Deliberately below the rules. What may replace what, which images to send and
86
+ * how a reader tells a newer format from nothing are decided once, in
87
+ * {@link publishLine} and {@link readLine}, so a directory, a bucket and a git
88
+ * ref cannot disagree about them.
89
+ */
90
+ export interface LineCell {
91
+ /** The manifest and an opaque version to write against, or why not. */
92
+ load(line: ShareLine): Promise<{
93
+ readonly manifest: Uint8Array;
94
+ readonly version: string;
95
+ } | ShareMiss>;
96
+ blob(line: ShareLine, path: BlobPath): Promise<Uint8Array | ShareMiss>;
97
+ /**
98
+ * Replace the manifest, if the held version is still `expected`.
99
+ *
100
+ * `expected` absent means the line must not exist yet. `blobs` holds only
101
+ * what the held line lacks; every other path the manifest names is already
102
+ * there, and a backend that builds on the held version relies on that.
103
+ */
104
+ store(line: ShareLine, write: {
105
+ readonly manifest: Uint8Array;
106
+ readonly blobs: ReadonlyMap<BlobPath, Uint8Array>;
107
+ readonly expected?: string;
108
+ }): Promise<'written' | 'conflict' | ShareMiss>;
109
+ }
110
+ /** An entry name, read. */
111
+ export interface EntryName {
112
+ readonly family: string;
113
+ readonly version: number;
114
+ readonly qualifier?: string;
115
+ }
116
+ /** Read an entry name, or refuse it. */
117
+ export declare function parseEntryName(name: string): EntryName | undefined;
118
+ /** Two entries are the same slot when they differ at most in version. */
119
+ export declare function sameSlot(a: EntryName, b: EntryName): boolean;
120
+ /**
121
+ * The path of a line inside a store, `mainline/release/2.0`.
122
+ *
123
+ * Each segment of the branch name is folded to key characters, a
124
+ * deliberately many-to-one mapping. Two branches whose names differ only in
125
+ * folded characters share a line, and a reader tells them apart by whether its
126
+ * `HEAD` contains the commit the line names.
127
+ */
128
+ export declare function linePath(line: ShareLine): string;
129
+ /** The digest an entry's bytes are held under. */
130
+ export declare function entryDigest(bytes: Uint8Array): string;
131
+ /**
132
+ * The manifest as bytes: canonical, ASCII, byte-stable.
133
+ *
134
+ * Every field is checked to be ASCII before it is written — names, commits and
135
+ * digests already are — so no text encoder is needed and two runtimes cannot
136
+ * write two spellings of one manifest.
137
+ */
138
+ export declare function encodeManifest(manifest: LineManifest): Uint8Array;
139
+ /**
140
+ * Read a manifest, or say why it is not one.
141
+ *
142
+ * A `format` above 1 is a newer writer, not a broken one, and is answered as
143
+ * such: the reader's action is to upgrade, not to republish.
144
+ */
145
+ export declare function decodeManifest(bytes: Uint8Array): LineManifest | ShareMiss;
146
+ /** Whether an entry offered for publishing is well formed, and why not. */
147
+ export declare function entryProblem(entry: ShareEntry): string | undefined;
148
+ /**
149
+ * Every blob path a manifest names. A backend that holds one line as one tree,
150
+ * a git ref, keeps exactly these and drops the rest when it writes.
151
+ */
152
+ export declare function manifestPaths(manifest: LineManifest): BlobPath[];
153
+ //# sourceMappingURL=line.d.ts.map
@@ -0,0 +1,184 @@
1
+ /**
2
+ * A line: the latest of what CI derived on one mainline or one branch.
3
+ *
4
+ * A line answers the question a checkout actually has — *what is mainline now*
5
+ * — by holding one version and replacing it, rather than keeping one artifact
6
+ * per commit for a reader to walk history in search of. Work is branched off a
7
+ * mainline and brought up to date with it before it merges, so the latest is
8
+ * the right answer, and the distance from a checkout to it is the instruction
9
+ * when it is not.
10
+ *
11
+ * A line holds **entries**, each a versioned artifact derived at one commit: the
12
+ * run report, one record per suite, the suite index. They are entries rather
13
+ * than one bundle because they arrive from different jobs, and a publish replaces
14
+ * only what it carries. It also holds the **images** its entries name, by digest,
15
+ * because an image does not compress and costs nothing to keep once.
16
+ *
17
+ * Nothing here collapses a failure into a miss. A reader has to be told whether
18
+ * nothing was published, it lacks the credentials, the store could not be
19
+ * reached, or a newer writer left a format it cannot read, because each of those
20
+ * asks for a different action.
21
+ */
22
+ import { sha256HexBytes } from '../format/sha256.js';
23
+ const ENTRY = /^([a-z][a-z0-9-]*)-v([1-9][0-9]*)(?:\/([A-Za-z0-9._-]+))?$/;
24
+ const COMMIT = /^[0-9a-f]{4,64}$/;
25
+ const DIGEST = /^[0-9a-f]{64}$/;
26
+ /** Read an entry name, or refuse it. */
27
+ export function parseEntryName(name) {
28
+ const match = ENTRY.exec(name);
29
+ if (match === null)
30
+ return undefined;
31
+ const qualifier = match[3];
32
+ return {
33
+ family: match[1],
34
+ version: Number(match[2]),
35
+ ...(qualifier !== undefined && qualifier !== '.' && qualifier !== '..' ? { qualifier } : {}),
36
+ };
37
+ }
38
+ /** Two entries are the same slot when they differ at most in version. */
39
+ export function sameSlot(a, b) {
40
+ return a.family === b.family && a.qualifier === b.qualifier;
41
+ }
42
+ /**
43
+ * The path of a line inside a store, `mainline/release/2.0`.
44
+ *
45
+ * Each segment of the branch name is folded to key characters, a
46
+ * deliberately many-to-one mapping. Two branches whose names differ only in
47
+ * folded characters share a line, and a reader tells them apart by whether its
48
+ * `HEAD` contains the commit the line names.
49
+ */
50
+ export function linePath(line) {
51
+ const segments = line.name
52
+ .split('/')
53
+ .map((part) => part.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, ''))
54
+ .filter((part) => part !== '');
55
+ return `${line.kind}/${segments.length === 0 ? 'unnamed' : segments.join('/')}`;
56
+ }
57
+ /** The digest an entry's bytes are held under. */
58
+ export function entryDigest(bytes) {
59
+ return sha256HexBytes(bytes);
60
+ }
61
+ /**
62
+ * The manifest as bytes: canonical, ASCII, byte-stable.
63
+ *
64
+ * Every field is checked to be ASCII before it is written — names, commits and
65
+ * digests already are — so no text encoder is needed and two runtimes cannot
66
+ * write two spellings of one manifest.
67
+ */
68
+ export function encodeManifest(manifest) {
69
+ const entries = [...manifest.entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
70
+ const text = JSON.stringify({
71
+ format: manifest.format,
72
+ entries: entries.map((entry) => ({
73
+ name: entry.name,
74
+ commit: entry.commit,
75
+ ...(entry.head !== undefined ? { head: entry.head } : {}),
76
+ ...(entry.images !== undefined ? { images: [...entry.images].sort() } : {}),
77
+ digest: entry.digest,
78
+ size: entry.size,
79
+ })),
80
+ });
81
+ const bytes = new Uint8Array(text.length);
82
+ for (let i = 0; i < text.length; i += 1) {
83
+ const code = text.charCodeAt(i);
84
+ if (code > 0x7e)
85
+ throw new Error(`a manifest holds ASCII only, and this one does not at ${String(i)}`);
86
+ bytes[i] = code;
87
+ }
88
+ return bytes;
89
+ }
90
+ /**
91
+ * Read a manifest, or say why it is not one.
92
+ *
93
+ * A `format` above 1 is a newer writer, not a broken one, and is answered as
94
+ * such: the reader's action is to upgrade, not to republish.
95
+ */
96
+ export function decodeManifest(bytes) {
97
+ let text = '';
98
+ for (const byte of bytes) {
99
+ if (byte > 0x7e)
100
+ return { kind: 'unreadable', detail: 'the manifest is not ASCII' };
101
+ text += String.fromCharCode(byte);
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(text);
106
+ }
107
+ catch {
108
+ return { kind: 'unreadable', detail: 'the manifest is not JSON' };
109
+ }
110
+ if (typeof parsed !== 'object' || parsed === null) {
111
+ return { kind: 'unreadable', detail: 'the manifest is not an object' };
112
+ }
113
+ const { format, entries } = parsed;
114
+ if (typeof format === 'number' && format > 1)
115
+ return { kind: 'newer', names: [`manifest format ${String(format)}`] };
116
+ if (format !== 1 || !Array.isArray(entries)) {
117
+ return { kind: 'unreadable', detail: 'the manifest has no format 1 entry list' };
118
+ }
119
+ const held = [];
120
+ for (const entry of entries) {
121
+ const checked = heldEntry(entry);
122
+ if (checked === undefined)
123
+ return { kind: 'unreadable', detail: 'the manifest holds a malformed entry' };
124
+ held.push(checked);
125
+ }
126
+ return { format: 1, entries: held };
127
+ }
128
+ function heldEntry(value) {
129
+ if (typeof value !== 'object' || value === null)
130
+ return undefined;
131
+ const { name, commit, head, images, digest, size } = value;
132
+ if (typeof name !== 'string' || parseEntryName(name) === undefined)
133
+ return undefined;
134
+ if (typeof commit !== 'string' || !COMMIT.test(commit))
135
+ return undefined;
136
+ if (head !== undefined && (typeof head !== 'string' || !COMMIT.test(head)))
137
+ return undefined;
138
+ if (typeof digest !== 'string' || !DIGEST.test(digest))
139
+ return undefined;
140
+ if (typeof size !== 'number' || !Number.isInteger(size) || size < 0)
141
+ return undefined;
142
+ if (images !== undefined) {
143
+ if (!Array.isArray(images) || !images.every((image) => typeof image === 'string' && DIGEST.test(image))) {
144
+ return undefined;
145
+ }
146
+ }
147
+ return {
148
+ name,
149
+ commit,
150
+ ...(head !== undefined ? { head: head } : {}),
151
+ ...(images !== undefined ? { images: images } : {}),
152
+ digest,
153
+ size,
154
+ };
155
+ }
156
+ /** Whether an entry offered for publishing is well formed, and why not. */
157
+ export function entryProblem(entry) {
158
+ if (parseEntryName(entry.name) === undefined) {
159
+ return `"${entry.name}" is not <family>-v<version>[/<qualifier>]`;
160
+ }
161
+ if (!COMMIT.test(entry.commit))
162
+ return `${entry.name}: "${entry.commit}" is not a commit`;
163
+ if (entry.head !== undefined && !COMMIT.test(entry.head))
164
+ return `${entry.name}: "${entry.head}" is not a commit`;
165
+ for (const image of entry.images ?? []) {
166
+ if (!DIGEST.test(image))
167
+ return `${entry.name}: "${image}" is not a sha-256 digest`;
168
+ }
169
+ return undefined;
170
+ }
171
+ /**
172
+ * Every blob path a manifest names. A backend that holds one line as one tree,
173
+ * a git ref, keeps exactly these and drops the rest when it writes.
174
+ */
175
+ export function manifestPaths(manifest) {
176
+ const paths = new Set();
177
+ for (const entry of manifest.entries) {
178
+ paths.add(`entries/${entry.digest}`);
179
+ for (const image of entry.images ?? [])
180
+ paths.add(`images/${image}`);
181
+ }
182
+ return [...paths].sort();
183
+ }
184
+ //# sourceMappingURL=line.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"line.js","sourceRoot":"","sources":["../../src/share/line.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A line: the latest of what CI derived on one mainline or one branch.\n *\n * A line answers the question a checkout actually has — *what is mainline now*\n * — by holding one version and replacing it, rather than keeping one artifact\n * per commit for a reader to walk history in search of. Work is branched off a\n * mainline and brought up to date with it before it merges, so the latest is\n * the right answer, and the distance from a checkout to it is the instruction\n * when it is not.\n *\n * A line holds **entries**, each a versioned artifact derived at one commit: the\n * run report, one record per suite, the suite index. They are entries rather\n * than one bundle because they arrive from different jobs, and a publish replaces\n * only what it carries. It also holds the **images** its entries name, by digest,\n * because an image does not compress and costs nothing to keep once.\n *\n * Nothing here collapses a failure into a miss. A reader has to be told whether\n * nothing was published, it lacks the credentials, the store could not be\n * reached, or a newer writer left a format it cannot read, because each of those\n * asks for a different action.\n */\n\nimport { sha256HexBytes } from '../format/sha256.js';\n\n/** One line a share holds the latest of. */\nexport interface ShareLine {\n readonly kind: 'mainline' | 'branch';\n /** The branch name as git spells it, `release/2.0` included. */\n readonly name: string;\n}\n\n/** An artifact offered to a line. */\nexport interface ShareEntry {\n /** `<family>-v<version>`, optionally `/<qualifier>`: `report-v1`, `suite-v1/web`. */\n readonly name: string;\n /** The commit it was derived at. For a pull request, the merge CI ran on. */\n readonly commit: string;\n /** The commit a pull request pointed at, when `commit` is the merge made for it. */\n readonly head?: string;\n /** Digests of the images this entry names, which the line keeps while it keeps the entry. */\n readonly images?: readonly string[];\n readonly bytes: Uint8Array;\n}\n\n/** An entry as the line holds it: what it is, where its bytes are, and nothing else. */\nexport interface HeldEntry {\n readonly name: string;\n readonly commit: string;\n readonly head?: string;\n readonly images?: readonly string[];\n readonly digest: string;\n readonly size: number;\n}\n\n/** What a line holds now. */\nexport interface LineManifest {\n readonly format: 1;\n /** Sorted by name, in code-unit order. */\n readonly entries: readonly HeldEntry[];\n}\n\n/**\n * Why a line could not answer.\n *\n * - `absent`: nothing was published under that name.\n * - `newer`: something was, in a format this reader does not know.\n * - `refused`: the store answered, and said no.\n * - `unreachable`: the store did not answer, or not in time.\n * - `unreadable`: the store answered with bytes that are not what they claim.\n */\nexport type ShareMiss =\n | { readonly kind: 'absent' }\n | { readonly kind: 'newer'; readonly names: readonly string[] }\n | { readonly kind: 'refused'; readonly detail: string }\n | { readonly kind: 'unreachable'; readonly detail: string }\n | { readonly kind: 'unreadable'; readonly detail: string };\n\n/** A path inside a line: an entry's bytes or an image, both by digest. */\nexport type BlobPath = `entries/${string}` | `images/${string}`;\n\n/**\n * The one thing a backend has to provide: a manifest that is replaced only if\n * nobody replaced it first, and the blobs it names.\n *\n * Deliberately below the rules. What may replace what, which images to send and\n * how a reader tells a newer format from nothing are decided once, in\n * {@link publishLine} and {@link readLine}, so a directory, a bucket and a git\n * ref cannot disagree about them.\n */\nexport interface LineCell {\n /** The manifest and an opaque version to write against, or why not. */\n load(line: ShareLine): Promise<{ readonly manifest: Uint8Array; readonly version: string } | ShareMiss>;\n blob(line: ShareLine, path: BlobPath): Promise<Uint8Array | ShareMiss>;\n /**\n * Replace the manifest, if the held version is still `expected`.\n *\n * `expected` absent means the line must not exist yet. `blobs` holds only\n * what the held line lacks; every other path the manifest names is already\n * there, and a backend that builds on the held version relies on that.\n */\n store(\n line: ShareLine,\n write: {\n readonly manifest: Uint8Array;\n readonly blobs: ReadonlyMap<BlobPath, Uint8Array>;\n readonly expected?: string;\n },\n ): Promise<'written' | 'conflict' | ShareMiss>;\n}\n\n/** An entry name, read. */\nexport interface EntryName {\n readonly family: string;\n readonly version: number;\n readonly qualifier?: string;\n}\n\nconst ENTRY = /^([a-z][a-z0-9-]*)-v([1-9][0-9]*)(?:\\/([A-Za-z0-9._-]+))?$/;\nconst COMMIT = /^[0-9a-f]{4,64}$/;\nconst DIGEST = /^[0-9a-f]{64}$/;\n\n/** Read an entry name, or refuse it. */\nexport function parseEntryName(name: string): EntryName | undefined {\n const match = ENTRY.exec(name);\n if (match === null) return undefined;\n const qualifier = match[3];\n return {\n family: match[1]!,\n version: Number(match[2]),\n ...(qualifier !== undefined && qualifier !== '.' && qualifier !== '..' ? { qualifier } : {}),\n };\n}\n\n/** Two entries are the same slot when they differ at most in version. */\nexport function sameSlot(a: EntryName, b: EntryName): boolean {\n return a.family === b.family && a.qualifier === b.qualifier;\n}\n\n/**\n * The path of a line inside a store, `mainline/release/2.0`.\n *\n * Each segment of the branch name is folded to key characters, a\n * deliberately many-to-one mapping. Two branches whose names differ only in\n * folded characters share a line, and a reader tells them apart by whether its\n * `HEAD` contains the commit the line names.\n */\nexport function linePath(line: ShareLine): string {\n const segments = line.name\n .split('/')\n .map((part) => part.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, ''))\n .filter((part) => part !== '');\n return `${line.kind}/${segments.length === 0 ? 'unnamed' : segments.join('/')}`;\n}\n\n/** The digest an entry's bytes are held under. */\nexport function entryDigest(bytes: Uint8Array): string {\n return sha256HexBytes(bytes);\n}\n\n/**\n * The manifest as bytes: canonical, ASCII, byte-stable.\n *\n * Every field is checked to be ASCII before it is written — names, commits and\n * digests already are — so no text encoder is needed and two runtimes cannot\n * write two spellings of one manifest.\n */\nexport function encodeManifest(manifest: LineManifest): Uint8Array {\n const entries = [...manifest.entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));\n const text = JSON.stringify({\n format: manifest.format,\n entries: entries.map((entry) => ({\n name: entry.name,\n commit: entry.commit,\n ...(entry.head !== undefined ? { head: entry.head } : {}),\n ...(entry.images !== undefined ? { images: [...entry.images].sort() } : {}),\n digest: entry.digest,\n size: entry.size,\n })),\n });\n const bytes = new Uint8Array(text.length);\n for (let i = 0; i < text.length; i += 1) {\n const code = text.charCodeAt(i);\n if (code > 0x7e) throw new Error(`a manifest holds ASCII only, and this one does not at ${String(i)}`);\n bytes[i] = code;\n }\n return bytes;\n}\n\n/**\n * Read a manifest, or say why it is not one.\n *\n * A `format` above 1 is a newer writer, not a broken one, and is answered as\n * such: the reader's action is to upgrade, not to republish.\n */\nexport function decodeManifest(bytes: Uint8Array): LineManifest | ShareMiss {\n let text = '';\n for (const byte of bytes) {\n if (byte > 0x7e) return { kind: 'unreadable', detail: 'the manifest is not ASCII' };\n text += String.fromCharCode(byte);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return { kind: 'unreadable', detail: 'the manifest is not JSON' };\n }\n if (typeof parsed !== 'object' || parsed === null) {\n return { kind: 'unreadable', detail: 'the manifest is not an object' };\n }\n const { format, entries } = parsed as { format?: unknown; entries?: unknown };\n if (typeof format === 'number' && format > 1) return { kind: 'newer', names: [`manifest format ${String(format)}`] };\n if (format !== 1 || !Array.isArray(entries)) {\n return { kind: 'unreadable', detail: 'the manifest has no format 1 entry list' };\n }\n const held: HeldEntry[] = [];\n for (const entry of entries as unknown[]) {\n const checked = heldEntry(entry);\n if (checked === undefined) return { kind: 'unreadable', detail: 'the manifest holds a malformed entry' };\n held.push(checked);\n }\n return { format: 1, entries: held };\n}\n\nfunction heldEntry(value: unknown): HeldEntry | undefined {\n if (typeof value !== 'object' || value === null) return undefined;\n const { name, commit, head, images, digest, size } = value as Record<string, unknown>;\n if (typeof name !== 'string' || parseEntryName(name) === undefined) return undefined;\n if (typeof commit !== 'string' || !COMMIT.test(commit)) return undefined;\n if (head !== undefined && (typeof head !== 'string' || !COMMIT.test(head))) return undefined;\n if (typeof digest !== 'string' || !DIGEST.test(digest)) return undefined;\n if (typeof size !== 'number' || !Number.isInteger(size) || size < 0) return undefined;\n if (images !== undefined) {\n if (!Array.isArray(images) || !images.every((image) => typeof image === 'string' && DIGEST.test(image))) {\n return undefined;\n }\n }\n return {\n name,\n commit,\n ...(head !== undefined ? { head: head as string } : {}),\n ...(images !== undefined ? { images: images as string[] } : {}),\n digest,\n size,\n };\n}\n\n/** Whether an entry offered for publishing is well formed, and why not. */\nexport function entryProblem(entry: ShareEntry): string | undefined {\n if (parseEntryName(entry.name) === undefined) {\n return `\"${entry.name}\" is not <family>-v<version>[/<qualifier>]`;\n }\n if (!COMMIT.test(entry.commit)) return `${entry.name}: \"${entry.commit}\" is not a commit`;\n if (entry.head !== undefined && !COMMIT.test(entry.head)) return `${entry.name}: \"${entry.head}\" is not a commit`;\n for (const image of entry.images ?? []) {\n if (!DIGEST.test(image)) return `${entry.name}: \"${image}\" is not a sha-256 digest`;\n }\n return undefined;\n}\n\n/**\n * Every blob path a manifest names. A backend that holds one line as one tree,\n * a git ref, keeps exactly these and drops the rest when it writes.\n */\nexport function manifestPaths(manifest: LineManifest): BlobPath[] {\n const paths = new Set<BlobPath>();\n for (const entry of manifest.entries) {\n paths.add(`entries/${entry.digest}`);\n for (const image of entry.images ?? []) paths.add(`images/${image}`);\n }\n return [...paths].sort();\n}\n"]}