@bygghammar/cms-platform 0.91.1 → 0.92.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 +115 -0
- package/README.md +20 -9
- package/docs/NEW-SITE.md +29 -14
- package/docs/PAGES.md +79 -21
- package/docs/PUBLICATION-REVIEW.md +298 -0
- package/migrations/20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24.sql +3298 -0
- package/package.json +8 -2
- package/scripts/cms-review.mjs +832 -0
- package/scripts/review-fingerprint.sql +43 -0
- package/scripts/review-scan.sql +56 -0
- package/src/backup/datasets.ts +203 -2
- package/src/backup/restore-core.ts +382 -11
- package/src/platform/contract.ts +82 -60
- package/src/platform/digest.ts +203 -0
- package/src/platform/migration-manifest.ts +310 -0
- package/src/platform/publication-review.ts +413 -0
- package/src/server/backup-core.ts +291 -18
- package/src/server/page-seo-core.ts +56 -10
- package/src/server/pages-core.ts +79 -46
- package/src/server/redirects-core.ts +51 -9
- package/src/server/schema-drift.ts +21 -65
- package/src/site/route-index.ts +641 -0
- package/src/site/routes.ts +2 -8
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the upgrade would do to every page, decided once and written down.
|
|
3
|
+
*
|
|
4
|
+
* Between 0.90.0-m7.12 and 0.91.1 the rule for "which version does a visitor
|
|
5
|
+
* see" changed, and the change was not additive. The old reader was:
|
|
6
|
+
*
|
|
7
|
+
* take the newest active publication row whose window is open —
|
|
8
|
+
* ORDER BY publish_at DESC LIMIT 1 — and if there is none, fall back to
|
|
9
|
+
* pages.current_version_id.
|
|
10
|
+
*
|
|
11
|
+
* The new one drops that fallback and adds a published_at tie-break. For most
|
|
12
|
+
* pages the two agree and nothing happens. For a page that was published by
|
|
13
|
+
* the old direct-pointer path and never got a publication row, the old reader
|
|
14
|
+
* served it and the new one serves nothing at all: the page goes dark, quietly,
|
|
15
|
+
* at the moment of upgrade, and the first anyone hears of it is a visitor.
|
|
16
|
+
*
|
|
17
|
+
* This module works out which pages those are before the upgrade rather than
|
|
18
|
+
* after. It takes the rows as they stand and answers, for each page, what each
|
|
19
|
+
* reader would serve and what that difference means. Seven answers, four of
|
|
20
|
+
* which are "nothing to do here" and three of which are somebody's decision:
|
|
21
|
+
*
|
|
22
|
+
* already_safe both readers serve the same version
|
|
23
|
+
* preserved the old reader served a publication row; it survives
|
|
24
|
+
* hidden_by_expiry it stopped being public before the upgrade, on time
|
|
25
|
+
* hidden_by_design it is not a published page and was never public
|
|
26
|
+
* disagreement both serve something, and they differ ← decide
|
|
27
|
+
* needs_review which version was served is not knowable ← decide
|
|
28
|
+
* structurally_invalid the rows contradict themselves ← decide
|
|
29
|
+
*
|
|
30
|
+
* The distinction that earns its keep is `preserved` against `hidden_by_expiry`.
|
|
31
|
+
* Both describe a page the new reader will not serve. In the first case that is
|
|
32
|
+
* an accident of the dropped fallback and the page was live yesterday; in the
|
|
33
|
+
* second the page's own unpublish_at had already passed and it was *already*
|
|
34
|
+
* dark. Collapsing them would either hide pages that should stay up or resurrect
|
|
35
|
+
* pages somebody deliberately retired.
|
|
36
|
+
*
|
|
37
|
+
* The same seven answers are computed a second time in SQL, inside the contract
|
|
38
|
+
* v3 migration, because the migration cannot call this code. `canonicalInputs`
|
|
39
|
+
* exists so the two can be compared: both sides digest the same shape, and a
|
|
40
|
+
* pinned corpus proves they agree case by case.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// Spelled with the extension, which the rest of `src/` does not do, because
|
|
44
|
+
// `scripts/cms-review.mjs` imports this module under a plain node: node strips
|
|
45
|
+
// the types but does not resolve an extensionless specifier, so dropping the
|
|
46
|
+
// `.ts` here to match the surrounding convention stops the CLI from starting.
|
|
47
|
+
import { canonicalJson, type DigestValue } from "./digest.ts";
|
|
48
|
+
|
|
49
|
+
/** One active publication row, as both readers see it. */
|
|
50
|
+
export type ReviewPublicationRow = {
|
|
51
|
+
id: string;
|
|
52
|
+
versionId: string | null;
|
|
53
|
+
publishAt: string | null;
|
|
54
|
+
unpublishAt: string | null;
|
|
55
|
+
publishedAt: string | null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** One page and the rows that decide what it serves. */
|
|
59
|
+
export type ReviewPageInput = {
|
|
60
|
+
pageId: string;
|
|
61
|
+
pageKey: string;
|
|
62
|
+
status: string;
|
|
63
|
+
currentVersionId: string | null;
|
|
64
|
+
/** Active rows only. Inactive rows are invisible to both readers. */
|
|
65
|
+
publications: readonly ReviewPublicationRow[];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const REVIEW_CLASSIFICATIONS = [
|
|
69
|
+
"structurally_invalid",
|
|
70
|
+
"already_safe",
|
|
71
|
+
"disagreement",
|
|
72
|
+
"hidden_by_design",
|
|
73
|
+
"hidden_by_expiry",
|
|
74
|
+
"preserved",
|
|
75
|
+
"needs_review",
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
export type ReviewClassification = (typeof REVIEW_CLASSIFICATIONS)[number];
|
|
79
|
+
|
|
80
|
+
/** The three that a person has to answer before the upgrade may proceed. */
|
|
81
|
+
const DECISION_REQUIRED: ReadonlySet<ReviewClassification> = new Set([
|
|
82
|
+
"structurally_invalid",
|
|
83
|
+
"disagreement",
|
|
84
|
+
"needs_review",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
export const REVIEW_DECISIONS = ["publish-legacy-version", "leave-hidden", "no-change"] as const;
|
|
88
|
+
|
|
89
|
+
export type ReviewDecision = (typeof REVIEW_DECISIONS)[number];
|
|
90
|
+
|
|
91
|
+
/** What the scan worked out about one page. */
|
|
92
|
+
export type ReviewItem = {
|
|
93
|
+
pageId: string;
|
|
94
|
+
pageKey: string;
|
|
95
|
+
classification: ReviewClassification;
|
|
96
|
+
requiresDecision: boolean;
|
|
97
|
+
/** What 0.90.0-m7.12 would serve. */
|
|
98
|
+
legacyVersionId: string | null;
|
|
99
|
+
/** What 0.91.1 would serve. */
|
|
100
|
+
v2VersionId: string | null;
|
|
101
|
+
/** True when the legacy answer came from a row, false when from the fallback. */
|
|
102
|
+
legacyFromPublication: boolean;
|
|
103
|
+
governingPublicationId: string | null;
|
|
104
|
+
scheduledVersionId: string | null;
|
|
105
|
+
scheduledPublishAt: string | null;
|
|
106
|
+
ambiguousOrder: boolean;
|
|
107
|
+
reason: string | null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The statuses a page may hold, taken from the domain `cms_set_page_status`
|
|
112
|
+
* enforces. `pages.status` carries no CHECK constraint, so a direct UPDATE can
|
|
113
|
+
* put anything at all in it — which is why "outside its domain" is one of the
|
|
114
|
+
* structural faults rather than an impossibility.
|
|
115
|
+
*/
|
|
116
|
+
const PAGE_STATUSES: ReadonlySet<string> = new Set(["draft", "published"]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Times arrive as strings and are compared as instants. A row whose timestamp
|
|
120
|
+
* does not parse is not "far in the future", it is broken, and saying so is the
|
|
121
|
+
* whole point of `structurally_invalid`.
|
|
122
|
+
*/
|
|
123
|
+
function instant(value: string | null): number | null {
|
|
124
|
+
if (value === null) return null;
|
|
125
|
+
const parsed = Date.parse(value);
|
|
126
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Prepared = {
|
|
130
|
+
row: ReviewPublicationRow;
|
|
131
|
+
publishAt: number;
|
|
132
|
+
unpublishAt: number | null;
|
|
133
|
+
publishedAt: number | null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Everything wrong with a page's rows, in one pass, so the reason can say which
|
|
138
|
+
* of several problems was found rather than only the first.
|
|
139
|
+
*/
|
|
140
|
+
function structuralFaults(page: ReviewPageInput, versionOwners: ReadonlyMap<string, string>): string[] {
|
|
141
|
+
const faults: string[] = [];
|
|
142
|
+
|
|
143
|
+
if (!PAGE_STATUSES.has(page.status)) {
|
|
144
|
+
faults.push(`page status ${JSON.stringify(page.status)} is not one this schema defines`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const owns = (versionId: string): boolean | null => {
|
|
148
|
+
const owner = versionOwners.get(versionId);
|
|
149
|
+
if (owner === undefined) return null;
|
|
150
|
+
return owner === page.pageId;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (page.currentVersionId !== null) {
|
|
154
|
+
const owned = owns(page.currentVersionId);
|
|
155
|
+
if (owned === null) faults.push("current_version_id points at a version that does not exist");
|
|
156
|
+
else if (!owned) faults.push("current_version_id points at another page's version");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const row of page.publications) {
|
|
160
|
+
if (row.versionId === null) {
|
|
161
|
+
faults.push(`publication ${row.id} names no version`);
|
|
162
|
+
} else {
|
|
163
|
+
const owned = owns(row.versionId);
|
|
164
|
+
if (owned === null) faults.push(`publication ${row.id} points at a version that does not exist`);
|
|
165
|
+
else if (!owned) faults.push(`publication ${row.id} points at another page's version`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const publishAt = instant(row.publishAt);
|
|
169
|
+
if (publishAt === null) {
|
|
170
|
+
faults.push(`publication ${row.id} has no usable publish_at`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const unpublishAt = instant(row.unpublishAt);
|
|
174
|
+
if (row.unpublishAt !== null && unpublishAt === null) {
|
|
175
|
+
faults.push(`publication ${row.id} has an unreadable unpublish_at`);
|
|
176
|
+
} else if (unpublishAt !== null && unpublishAt <= publishAt) {
|
|
177
|
+
faults.push(`publication ${row.id} is scheduled to end before it starts`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return faults;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The 0.90.0-m7.12 reader, reproduced.
|
|
186
|
+
*
|
|
187
|
+
* .eq("is_active", true).lte("publish_at", now)
|
|
188
|
+
* .or("unpublish_at.is.null,unpublish_at.gt." + now)
|
|
189
|
+
* .order("publish_at", { ascending: false }).limit(1)
|
|
190
|
+
* versionId = pub?.version_id ?? page.current_version_id
|
|
191
|
+
*
|
|
192
|
+
* Closed rows are filtered out *before* the newest is chosen, and a page with
|
|
193
|
+
* nothing left falls back to its current version pointer.
|
|
194
|
+
*
|
|
195
|
+
* `limit(1)` on a tie takes whichever row PostgreSQL reached first, which is
|
|
196
|
+
* physical order — not something a second implementation can reproduce, and not
|
|
197
|
+
* something anybody can read back. So the tie is broken by row id instead. That
|
|
198
|
+
* is not a claim about which row the old reader served: `isAmbiguous` reports
|
|
199
|
+
* the tie, and a page in that state is never resolved automatically. It only
|
|
200
|
+
* makes the *record* of the scan reproducible, which it must be, because the
|
|
201
|
+
* record is digested and a person signs the digest.
|
|
202
|
+
*/
|
|
203
|
+
function legacyAnswer(open: readonly Prepared[]): Prepared | null {
|
|
204
|
+
let best: Prepared | null = null;
|
|
205
|
+
for (const candidate of open) {
|
|
206
|
+
if (best === null || candidate.publishAt > best.publishAt) best = candidate;
|
|
207
|
+
else if (candidate.publishAt === best.publishAt && candidate.row.id < best.row.id) best = candidate;
|
|
208
|
+
}
|
|
209
|
+
return best;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The 0.91.1 reader, reproduced — and the order of its two steps is the whole
|
|
214
|
+
* difference.
|
|
215
|
+
*
|
|
216
|
+
* `governingPublication` picks the newest *started* row by (publish_at,
|
|
217
|
+
* published_at) without looking at unpublish_at at all; `publicVersionId` then
|
|
218
|
+
* asks whether that one row is still open, and serves nothing if it is not.
|
|
219
|
+
*
|
|
220
|
+
* So a newer row that has already expired does not step aside for an older one
|
|
221
|
+
* that is still open — it shadows it, and the page goes dark. Under 0.90 the
|
|
222
|
+
* expired row was filtered out first and the older row served the page as
|
|
223
|
+
* normal. That single inversion is what `preserved` is about, and it is why
|
|
224
|
+
* this cannot be written as "the same query with a tie-break".
|
|
225
|
+
*/
|
|
226
|
+
function v2Answer(started: readonly Prepared[], at: number): Prepared | null {
|
|
227
|
+
let best: Prepared | null = null;
|
|
228
|
+
for (const candidate of started) {
|
|
229
|
+
if (best === null) {
|
|
230
|
+
best = candidate;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (candidate.publishAt > best.publishAt) best = candidate;
|
|
234
|
+
else if (candidate.publishAt === best.publishAt) {
|
|
235
|
+
const a = candidate.publishedAt ?? Number.NEGATIVE_INFINITY;
|
|
236
|
+
const b = best.publishedAt ?? Number.NEGATIVE_INFINITY;
|
|
237
|
+
// Row id settles a full tie for the same reason it does above: to make
|
|
238
|
+
// the record reproducible, not to assert which row won in production.
|
|
239
|
+
if (a > b || (a === b && candidate.row.id < best.row.id)) best = candidate;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (best === null) return null;
|
|
243
|
+
return best.unpublishAt === null || best.unpublishAt > at ? best : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* True when the served version was never a function of the data alone.
|
|
248
|
+
*
|
|
249
|
+
* Two independent ways to lose determinism, one per reader. The old one breaks
|
|
250
|
+
* a publish_at tie among open rows by physical row order, which is not a
|
|
251
|
+
* property anybody can read back. The new one breaks it among started rows by
|
|
252
|
+
* published_at, which ties or is null often enough to leave the same hole. A
|
|
253
|
+
* page in either state has no "version it used to serve" to preserve, so it is
|
|
254
|
+
* never resolved automatically, whatever else is true of it.
|
|
255
|
+
*/
|
|
256
|
+
function isAmbiguous(open: readonly Prepared[], started: readonly Prepared[]): boolean {
|
|
257
|
+
const latestOf = (rows: readonly Prepared[]): number | null =>
|
|
258
|
+
rows.reduce<number | null>((max, row) => (max === null || row.publishAt > max ? row.publishAt : max), null);
|
|
259
|
+
|
|
260
|
+
const latestOpen = latestOf(open);
|
|
261
|
+
if (latestOpen !== null && open.filter((row) => row.publishAt === latestOpen).length > 1) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const latestStarted = latestOf(started);
|
|
266
|
+
if (latestStarted === null) return false;
|
|
267
|
+
const tied = started.filter((row) => row.publishAt === latestStarted);
|
|
268
|
+
if (tied.length < 2) return false;
|
|
269
|
+
if (tied.some((row) => row.publishedAt === null)) return true;
|
|
270
|
+
return new Set(tied.map((row) => row.publishedAt)).size < tied.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Classify one page.
|
|
275
|
+
*
|
|
276
|
+
* `versionOwners` maps version id to the page that owns it, which is what makes
|
|
277
|
+
* "this row points at another page's version" answerable — the single most
|
|
278
|
+
* confusing shape to inherit, because both readers happily serve it.
|
|
279
|
+
*/
|
|
280
|
+
export function classifyPage(
|
|
281
|
+
page: ReviewPageInput,
|
|
282
|
+
now: Date,
|
|
283
|
+
versionOwners: ReadonlyMap<string, string>,
|
|
284
|
+
): ReviewItem {
|
|
285
|
+
const at = now.getTime();
|
|
286
|
+
|
|
287
|
+
const faults = structuralFaults(page, versionOwners);
|
|
288
|
+
|
|
289
|
+
const prepared: Prepared[] = [];
|
|
290
|
+
for (const row of page.publications) {
|
|
291
|
+
const publishAt = instant(row.publishAt);
|
|
292
|
+
if (publishAt === null) continue;
|
|
293
|
+
prepared.push({ row, publishAt, unpublishAt: instant(row.unpublishAt), publishedAt: instant(row.publishedAt) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const started = prepared.filter((row) => row.publishAt <= at);
|
|
297
|
+
const open = started.filter((row) => row.unpublishAt === null || row.unpublishAt > at);
|
|
298
|
+
const hasStarted = started.length > 0;
|
|
299
|
+
|
|
300
|
+
// Provenance and value are settled together, because "which version" and
|
|
301
|
+
// "where it came from" have to agree even on a page whose rows are broken —
|
|
302
|
+
// the item records both, and a row naming no version is a fallback in
|
|
303
|
+
// practice however it got there.
|
|
304
|
+
const legacyRow = legacyAnswer(open);
|
|
305
|
+
const legacyFromPublication = legacyRow !== null && legacyRow.row.versionId !== null;
|
|
306
|
+
const legacyVersionId = legacyFromPublication ? legacyRow.row.versionId : page.currentVersionId;
|
|
307
|
+
|
|
308
|
+
const v2Row = v2Answer(started, at);
|
|
309
|
+
const v2VersionId = v2Row?.row.versionId ?? null;
|
|
310
|
+
|
|
311
|
+
const ambiguousOrder = isAmbiguous(open, started);
|
|
312
|
+
|
|
313
|
+
// The next window that has not opened yet — shown alongside the item so a
|
|
314
|
+
// reviewer can see that a page which looks dark today is scheduled to return.
|
|
315
|
+
const scheduled = prepared
|
|
316
|
+
.filter((row) => row.publishAt > at)
|
|
317
|
+
.sort((a, b) => a.publishAt - b.publishAt || (a.row.id < b.row.id ? -1 : 1))[0];
|
|
318
|
+
|
|
319
|
+
const base = {
|
|
320
|
+
pageId: page.pageId,
|
|
321
|
+
pageKey: page.pageKey,
|
|
322
|
+
legacyVersionId,
|
|
323
|
+
v2VersionId,
|
|
324
|
+
legacyFromPublication,
|
|
325
|
+
governingPublicationId: legacyRow?.row.id ?? null,
|
|
326
|
+
scheduledVersionId: scheduled?.row.versionId ?? null,
|
|
327
|
+
scheduledPublishAt: scheduled?.row.publishAt ?? null,
|
|
328
|
+
ambiguousOrder,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const decide = (classification: ReviewClassification, reason: string | null): ReviewItem => ({
|
|
332
|
+
...base,
|
|
333
|
+
classification,
|
|
334
|
+
requiresDecision: DECISION_REQUIRED.has(classification),
|
|
335
|
+
reason,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Evaluated in this order, and mutually exclusive by construction.
|
|
339
|
+
if (faults.length > 0) return decide("structurally_invalid", faults.join("; "));
|
|
340
|
+
|
|
341
|
+
if (legacyVersionId === v2VersionId) {
|
|
342
|
+
return decide("already_safe", null);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (legacyVersionId !== null && v2VersionId !== null) {
|
|
346
|
+
return decide("disagreement", "the two readers serve different versions of this page");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Everything below is in scope: the old reader served something, the new one
|
|
350
|
+
// serves nothing. The only question left is whether that is a loss.
|
|
351
|
+
if (page.status !== "published") {
|
|
352
|
+
return decide("hidden_by_design", `the page is ${page.status}, so it was not public either way`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (ambiguousOrder) {
|
|
356
|
+
return decide("needs_review", "ambiguous_order");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (hasStarted && !legacyFromPublication) {
|
|
360
|
+
return decide("hidden_by_expiry", "every publication window for this page had already closed");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (legacyFromPublication) {
|
|
364
|
+
return decide(
|
|
365
|
+
"preserved",
|
|
366
|
+
"an expired publication row shadows the open one, so the new reader serves nothing",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Two shapes reach here, and telling them apart is most of what a reviewer
|
|
371
|
+
// needs. Both are one database state carrying two opposite intentions, which
|
|
372
|
+
// is exactly why neither may be resolved on the operator's behalf.
|
|
373
|
+
if (scheduled !== undefined) {
|
|
374
|
+
return decide(
|
|
375
|
+
"needs_review",
|
|
376
|
+
"this page's only publication window has not opened yet, and the data cannot say whether it " +
|
|
377
|
+
"is meant to stay live until then or go dark now",
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return decide(
|
|
382
|
+
"needs_review",
|
|
383
|
+
"the page has no publication row at all, so what it used to serve cannot be established from the data",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The shape both sides digest.
|
|
389
|
+
*
|
|
390
|
+
* Field names are the SQL column names rather than the TypeScript ones, because
|
|
391
|
+
* the SQL side is the one that cannot be changed after release, and a mismatch
|
|
392
|
+
* here is a parity failure rather than a bug in either implementation.
|
|
393
|
+
*/
|
|
394
|
+
export function canonicalInputs(item: ReviewItem): DigestValue {
|
|
395
|
+
return {
|
|
396
|
+
ambiguous_order: item.ambiguousOrder,
|
|
397
|
+
classification: item.classification,
|
|
398
|
+
governing_publication_id: item.governingPublicationId,
|
|
399
|
+
legacy_from_publication: item.legacyFromPublication,
|
|
400
|
+
legacy_version_id: item.legacyVersionId,
|
|
401
|
+
page_id: item.pageId,
|
|
402
|
+
page_key: item.pageKey,
|
|
403
|
+
requires_decision: item.requiresDecision,
|
|
404
|
+
scheduled_publish_at: item.scheduledPublishAt,
|
|
405
|
+
scheduled_version_id: item.scheduledVersionId,
|
|
406
|
+
v2_version_id: item.v2VersionId,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The canonical text of an item's inputs, which is what gets hashed. */
|
|
411
|
+
export function reviewInputsText(item: ReviewItem): string {
|
|
412
|
+
return canonicalJson(canonicalInputs(item));
|
|
413
|
+
}
|