@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.
@@ -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
+ }