@growth-labs/cms 0.1.3 → 0.2.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/README.md +146 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts +210 -0
- package/dist/engine/publication.d.ts.map +1 -0
- package/dist/engine/publication.js +1436 -0
- package/dist/engine/publication.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +28 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +1 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +20 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0018_content_publication_attempts.sql +23 -0
- package/package.json +1 -1
- package/src/engine/index.ts +39 -0
- package/src/engine/publication.ts +2152 -0
- package/src/index.ts +3 -0
- package/src/schema/index.ts +3 -0
- package/src/schema/migrations.ts +28 -0
- package/src/schema/tables.ts +1 -0
- package/src/schema/types.ts +31 -0
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
import { evaluateArticleBody } from './publish-guard.js';
|
|
2
|
+
import { createContent, ensureUniqueSlug, getContentItem, getContentSnapshot, PublishContentValidationError, } from './publisher.js';
|
|
3
|
+
import { getRevision } from './revisions.js';
|
|
4
|
+
import { slugify } from './slug.js';
|
|
5
|
+
export class PublicationReconciliationError extends Error {
|
|
6
|
+
receipts;
|
|
7
|
+
attemptId;
|
|
8
|
+
constructor(message, receipts, attemptId = null) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'PublicationReconciliationError';
|
|
11
|
+
this.receipts = receipts;
|
|
12
|
+
this.attemptId = attemptId;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class PublicationSurfaceRequirementError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'PublicationSurfaceRequirementError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class PublicationPointerMoveError extends Error {
|
|
22
|
+
receipts;
|
|
23
|
+
attemptId;
|
|
24
|
+
constructor(message, receipts = [], attemptId = null) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'PublicationPointerMoveError';
|
|
27
|
+
this.receipts = receipts;
|
|
28
|
+
this.attemptId = attemptId;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class PublicationNotFoundError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'PublicationNotFoundError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
class PublicationAttemptConflictError extends Error {
|
|
38
|
+
current;
|
|
39
|
+
constructor(current) {
|
|
40
|
+
super(`publication attempt advanced concurrently to ${current.state}`);
|
|
41
|
+
this.name = 'PublicationAttemptConflictError';
|
|
42
|
+
this.current = current;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
class PublicationAttemptSupersededError extends PublicationPointerMoveError {
|
|
46
|
+
constructor(attempt) {
|
|
47
|
+
super('publication attempt was superseded by a newer canonical revision', attempt.receipts, attempt.id);
|
|
48
|
+
this.name = 'PublicationAttemptSupersededError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export class ContentArchiveImportLimitError extends Error {
|
|
52
|
+
code;
|
|
53
|
+
limit;
|
|
54
|
+
actual;
|
|
55
|
+
constructor(code, message, limit, actual) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'ContentArchiveImportLimitError';
|
|
58
|
+
this.code = code;
|
|
59
|
+
this.limit = limit;
|
|
60
|
+
this.actual = actual;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function nowSeconds() {
|
|
64
|
+
return Math.floor(Date.now() / 1000);
|
|
65
|
+
}
|
|
66
|
+
export const DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 10_000;
|
|
67
|
+
export const MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS = 30_000;
|
|
68
|
+
export const DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT = 100;
|
|
69
|
+
export const MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT = 500;
|
|
70
|
+
const MAX_PUBLICATION_ATTEMPT_JSON_BYTES = 64 * 1024;
|
|
71
|
+
function normalizeSurfaceHookTimeoutMs(value) {
|
|
72
|
+
const timeoutMs = value ?? DEFAULT_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS;
|
|
73
|
+
if (!Number.isSafeInteger(timeoutMs) ||
|
|
74
|
+
timeoutMs <= 0 ||
|
|
75
|
+
timeoutMs > MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS) {
|
|
76
|
+
throw new PublicationSurfaceRequirementError(`surfaceHookTimeoutMs must be an integer between 1 and ${MAX_PUBLICATION_SURFACE_HOOK_TIMEOUT_MS}`);
|
|
77
|
+
}
|
|
78
|
+
return timeoutMs;
|
|
79
|
+
}
|
|
80
|
+
function d1Changes(result) {
|
|
81
|
+
const changes = result.meta?.changes;
|
|
82
|
+
return typeof changes === 'number' ? changes : null;
|
|
83
|
+
}
|
|
84
|
+
const DURABLE_DIAGNOSTIC_MAX_LENGTH = 64;
|
|
85
|
+
const DURABLE_DIAGNOSTICS = [
|
|
86
|
+
'abort hook not implemented',
|
|
87
|
+
'abort_hook_not_implemented',
|
|
88
|
+
'canonical_pointer_advanced',
|
|
89
|
+
'canonical_pointer_move_abort_incomplete',
|
|
90
|
+
'canonical_pointer_move_failed',
|
|
91
|
+
'invalid_surface_receipt_duration',
|
|
92
|
+
'invalid_surface_receipt_message',
|
|
93
|
+
'invalid_surface_receipt_name',
|
|
94
|
+
'invalid_surface_receipt_object',
|
|
95
|
+
'invalid surface receipt status',
|
|
96
|
+
'invalid_durable_state',
|
|
97
|
+
'surface abort failed',
|
|
98
|
+
'surface_abort_incomplete',
|
|
99
|
+
'surface_commit_failed',
|
|
100
|
+
'surface_hook_failed',
|
|
101
|
+
'surface_hook_skipped',
|
|
102
|
+
'surface_hook_timed_out',
|
|
103
|
+
'surface_hook_threw',
|
|
104
|
+
'surface_prepare_failed',
|
|
105
|
+
'untrusted_diagnostic_redacted',
|
|
106
|
+
];
|
|
107
|
+
const DURABLE_DIAGNOSTIC_SET = new Set(DURABLE_DIAGNOSTICS);
|
|
108
|
+
function durableDiagnostic(code) {
|
|
109
|
+
if (code.length > DURABLE_DIAGNOSTIC_MAX_LENGTH) {
|
|
110
|
+
throw new PublicationReconciliationError('durable diagnostic exceeds its fixed bound', []);
|
|
111
|
+
}
|
|
112
|
+
return code;
|
|
113
|
+
}
|
|
114
|
+
function normalizeStoredDiagnostic(value) {
|
|
115
|
+
return value.length <= DURABLE_DIAGNOSTIC_MAX_LENGTH && DURABLE_DIAGNOSTIC_SET.has(value)
|
|
116
|
+
? value
|
|
117
|
+
: durableDiagnostic('untrusted_diagnostic_redacted');
|
|
118
|
+
}
|
|
119
|
+
function parseStringArray(value, field) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(value);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new PublicationReconciliationError(`publication attempt has invalid ${field}`, []);
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) {
|
|
128
|
+
throw new PublicationReconciliationError(`publication attempt has invalid ${field}`, []);
|
|
129
|
+
}
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
function parseReceipts(value) {
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(value);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new PublicationReconciliationError('publication attempt has invalid receipts_json', []);
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(parsed)) {
|
|
141
|
+
throw new PublicationReconciliationError('publication attempt has invalid receipts_json', []);
|
|
142
|
+
}
|
|
143
|
+
const receipts = [];
|
|
144
|
+
for (const receipt of parsed) {
|
|
145
|
+
const candidate = receipt;
|
|
146
|
+
if (typeof receipt !== 'object' ||
|
|
147
|
+
receipt === null ||
|
|
148
|
+
typeof candidate.name !== 'string' ||
|
|
149
|
+
(candidate.status !== 'ok' &&
|
|
150
|
+
candidate.status !== 'skipped' &&
|
|
151
|
+
candidate.status !== 'failed') ||
|
|
152
|
+
(candidate.phase !== undefined &&
|
|
153
|
+
candidate.phase !== 'prepare' &&
|
|
154
|
+
candidate.phase !== 'commit' &&
|
|
155
|
+
candidate.phase !== 'abort') ||
|
|
156
|
+
(candidate.message !== undefined && typeof candidate.message !== 'string') ||
|
|
157
|
+
(candidate.durationMs !== undefined &&
|
|
158
|
+
(typeof candidate.durationMs !== 'number' ||
|
|
159
|
+
!Number.isFinite(candidate.durationMs) ||
|
|
160
|
+
candidate.durationMs < 0))) {
|
|
161
|
+
throw new PublicationReconciliationError('publication attempt has invalid receipts_json', []);
|
|
162
|
+
}
|
|
163
|
+
receipts.push({
|
|
164
|
+
name: candidate.name,
|
|
165
|
+
status: candidate.status,
|
|
166
|
+
phase: candidate.phase,
|
|
167
|
+
message: typeof candidate.message === 'string'
|
|
168
|
+
? normalizeStoredDiagnostic(candidate.message)
|
|
169
|
+
: undefined,
|
|
170
|
+
durationMs: candidate.durationMs,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return receipts;
|
|
174
|
+
}
|
|
175
|
+
function compactPublicationReceipts(receipts) {
|
|
176
|
+
const compacted = [];
|
|
177
|
+
const indexes = new Map();
|
|
178
|
+
for (const receipt of receipts) {
|
|
179
|
+
const key = JSON.stringify([receipt.name, receipt.phase ?? null]);
|
|
180
|
+
const existingIndex = indexes.get(key);
|
|
181
|
+
if (existingIndex === undefined) {
|
|
182
|
+
indexes.set(key, compacted.length);
|
|
183
|
+
compacted.push(receipt);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
compacted[existingIndex] = receipt;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return compacted;
|
|
190
|
+
}
|
|
191
|
+
function decodeAttempt(row) {
|
|
192
|
+
return {
|
|
193
|
+
id: row.id,
|
|
194
|
+
operation: row.operation,
|
|
195
|
+
contentId: row.content_id,
|
|
196
|
+
revisionId: row.revision_id,
|
|
197
|
+
previousRevisionId: row.previous_revision_id,
|
|
198
|
+
state: row.state,
|
|
199
|
+
surfaceNames: parseStringArray(row.surface_names_json, 'surface_names_json'),
|
|
200
|
+
pendingSurfaceNames: parseStringArray(row.pending_surface_names_json, 'pending_surface_names_json'),
|
|
201
|
+
preparedSurfaceNames: parseStringArray(row.prepared_surface_names_json, 'prepared_surface_names_json'),
|
|
202
|
+
receipts: parseReceipts(row.receipts_json),
|
|
203
|
+
version: row.version,
|
|
204
|
+
packageVersion: row.package_version,
|
|
205
|
+
lastError: row.last_error === null ? null : normalizeStoredDiagnostic(row.last_error),
|
|
206
|
+
createdAt: row.created_at,
|
|
207
|
+
updatedAt: row.updated_at,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function recoverStringArray(value) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(value);
|
|
213
|
+
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function decodeAttemptForEnumeration(row) {
|
|
220
|
+
try {
|
|
221
|
+
return decodeAttempt(row);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
const diagnostic = durableDiagnostic('invalid_durable_state');
|
|
225
|
+
return {
|
|
226
|
+
id: row.id,
|
|
227
|
+
operation: row.operation,
|
|
228
|
+
contentId: row.content_id,
|
|
229
|
+
revisionId: row.revision_id,
|
|
230
|
+
previousRevisionId: row.previous_revision_id,
|
|
231
|
+
state: row.state,
|
|
232
|
+
surfaceNames: recoverStringArray(row.surface_names_json),
|
|
233
|
+
pendingSurfaceNames: recoverStringArray(row.pending_surface_names_json),
|
|
234
|
+
preparedSurfaceNames: recoverStringArray(row.prepared_surface_names_json),
|
|
235
|
+
receipts: [{ name: 'publication_attempt', status: 'failed', message: diagnostic }],
|
|
236
|
+
version: row.version,
|
|
237
|
+
packageVersion: row.package_version,
|
|
238
|
+
lastError: diagnostic,
|
|
239
|
+
recoveryError: 'invalid_durable_state',
|
|
240
|
+
createdAt: row.created_at,
|
|
241
|
+
updatedAt: row.updated_at,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function getPublicationAttempt(db, attemptId) {
|
|
246
|
+
const row = await db
|
|
247
|
+
.prepare(`SELECT id, operation, content_id, revision_id, previous_revision_id, state,
|
|
248
|
+
surface_names_json, pending_surface_names_json,
|
|
249
|
+
prepared_surface_names_json, receipts_json, version,
|
|
250
|
+
package_version, last_error, created_at, updated_at
|
|
251
|
+
FROM content_publication_attempts
|
|
252
|
+
WHERE id = ?`)
|
|
253
|
+
.bind(attemptId)
|
|
254
|
+
.first();
|
|
255
|
+
if (!row)
|
|
256
|
+
throw new PublicationNotFoundError(`publication attempt not found: ${attemptId}`);
|
|
257
|
+
return decodeAttempt(row);
|
|
258
|
+
}
|
|
259
|
+
export async function listPendingPublicationAttempts(db, options = {}) {
|
|
260
|
+
const limit = options.limit ?? DEFAULT_PENDING_PUBLICATION_ATTEMPT_LIMIT;
|
|
261
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT) {
|
|
262
|
+
throw new PublicationSurfaceRequirementError(`pending publication attempt limit must be an integer between 1 and ${MAX_PENDING_PUBLICATION_ATTEMPT_LIMIT}`);
|
|
263
|
+
}
|
|
264
|
+
const cursor = options.cursor;
|
|
265
|
+
if (cursor &&
|
|
266
|
+
(!Number.isSafeInteger(cursor.updatedAt) ||
|
|
267
|
+
cursor.updatedAt < 0 ||
|
|
268
|
+
typeof cursor.id !== 'string' ||
|
|
269
|
+
cursor.id.length === 0)) {
|
|
270
|
+
throw new PublicationSurfaceRequirementError('pending publication attempt cursor must contain a non-negative updatedAt and id');
|
|
271
|
+
}
|
|
272
|
+
const cursorClause = cursor ? 'AND (updated_at > ? OR (updated_at = ? AND id > ?))' : '';
|
|
273
|
+
const result = await db
|
|
274
|
+
.prepare(`SELECT id, operation, content_id, revision_id, previous_revision_id, state,
|
|
275
|
+
CASE WHEN length(CAST(surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
|
|
276
|
+
THEN surface_names_json ELSE 'null' END AS surface_names_json,
|
|
277
|
+
CASE WHEN length(CAST(pending_surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
|
|
278
|
+
THEN pending_surface_names_json ELSE 'null' END AS pending_surface_names_json,
|
|
279
|
+
CASE WHEN length(CAST(prepared_surface_names_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
|
|
280
|
+
THEN prepared_surface_names_json ELSE 'null' END AS prepared_surface_names_json,
|
|
281
|
+
CASE WHEN length(CAST(receipts_json AS BLOB)) <= ${MAX_PUBLICATION_ATTEMPT_JSON_BYTES}
|
|
282
|
+
THEN receipts_json ELSE 'null' END AS receipts_json,
|
|
283
|
+
version,
|
|
284
|
+
package_version, last_error, created_at, updated_at
|
|
285
|
+
FROM content_publication_attempts
|
|
286
|
+
WHERE (
|
|
287
|
+
state IN ('preparing', 'prepared', 'aborting', 'abort_failed')
|
|
288
|
+
OR (
|
|
289
|
+
state IN ('committing', 'pending_retry')
|
|
290
|
+
AND EXISTS (
|
|
291
|
+
SELECT 1 FROM content_items
|
|
292
|
+
WHERE content_items.id = content_publication_attempts.content_id
|
|
293
|
+
AND content_items.published_revision_id = content_publication_attempts.revision_id
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
${cursorClause}
|
|
298
|
+
ORDER BY updated_at ASC, id ASC
|
|
299
|
+
LIMIT ?`)
|
|
300
|
+
.bind(...(cursor ? [cursor.updatedAt, cursor.updatedAt, cursor.id, limit] : [limit]))
|
|
301
|
+
.all();
|
|
302
|
+
return (result.results ?? []).map(decodeAttemptForEnumeration);
|
|
303
|
+
}
|
|
304
|
+
async function createPublicationAttempt(db, input) {
|
|
305
|
+
const id = crypto.randomUUID();
|
|
306
|
+
await publicationAttemptInsert(db, id, input).run();
|
|
307
|
+
return getPublicationAttempt(db, id);
|
|
308
|
+
}
|
|
309
|
+
function publicationAttemptInsert(db, id, input) {
|
|
310
|
+
return db
|
|
311
|
+
.prepare(`INSERT INTO content_publication_attempts (
|
|
312
|
+
id, operation, content_id, revision_id, previous_revision_id, state,
|
|
313
|
+
surface_names_json, pending_surface_names_json,
|
|
314
|
+
prepared_surface_names_json, receipts_json,
|
|
315
|
+
package_version, created_at, updated_at
|
|
316
|
+
) VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, '[]', '[]', ?, ?, ?)`)
|
|
317
|
+
.bind(id, input.operation, input.contentId, input.revisionId, input.previousRevisionId, JSON.stringify(input.surfaceNames), JSON.stringify(input.surfaceNames), input.packageVersion, input.now, input.now);
|
|
318
|
+
}
|
|
319
|
+
async function persistPublicationAttempt(db, input) {
|
|
320
|
+
if (input.expectedStates.length === 0) {
|
|
321
|
+
throw new PublicationAttemptConflictError(await getPublicationAttempt(db, input.attemptId));
|
|
322
|
+
}
|
|
323
|
+
const receipts = compactPublicationReceipts(input.receipts);
|
|
324
|
+
const expectedStatePlaceholders = input.expectedStates.map(() => '?').join(', ');
|
|
325
|
+
const result = await db
|
|
326
|
+
.prepare(`UPDATE content_publication_attempts
|
|
327
|
+
SET state = ?, pending_surface_names_json = ?, prepared_surface_names_json = ?,
|
|
328
|
+
receipts_json = ?, last_error = ?, updated_at = ?, version = version + 1
|
|
329
|
+
WHERE id = ? AND version = ? AND state IN (${expectedStatePlaceholders})`)
|
|
330
|
+
.bind(input.state, JSON.stringify(input.pendingSurfaceNames), JSON.stringify(input.preparedSurfaceNames), JSON.stringify(receipts), input.lastError ?? null, input.now, input.attemptId, input.expectedVersion, ...input.expectedStates)
|
|
331
|
+
.run();
|
|
332
|
+
const changes = d1Changes(result);
|
|
333
|
+
if (changes !== null && changes !== 1) {
|
|
334
|
+
throw new PublicationAttemptConflictError(await getPublicationAttempt(db, input.attemptId));
|
|
335
|
+
}
|
|
336
|
+
const current = await getPublicationAttempt(db, input.attemptId);
|
|
337
|
+
const matchesIntendedWrite = current.version === input.expectedVersion + 1 &&
|
|
338
|
+
current.state === input.state &&
|
|
339
|
+
JSON.stringify(current.pendingSurfaceNames) === JSON.stringify(input.pendingSurfaceNames) &&
|
|
340
|
+
JSON.stringify(current.preparedSurfaceNames) === JSON.stringify(input.preparedSurfaceNames) &&
|
|
341
|
+
JSON.stringify(current.receipts) === JSON.stringify(receipts) &&
|
|
342
|
+
current.lastError === (input.lastError ?? null);
|
|
343
|
+
if (!matchesIntendedWrite)
|
|
344
|
+
throw new PublicationAttemptConflictError(current);
|
|
345
|
+
return current;
|
|
346
|
+
}
|
|
347
|
+
async function createRevisionAndPublicationAttempt(db, input) {
|
|
348
|
+
const snapshot = await getContentSnapshot(db, input.contentId);
|
|
349
|
+
if (!snapshot)
|
|
350
|
+
throw new PublicationNotFoundError(`content not found: ${input.contentId}`);
|
|
351
|
+
assertPublicationSnapshotBodyValid(snapshot, input.contentId);
|
|
352
|
+
const revisionId = crypto.randomUUID();
|
|
353
|
+
const attemptId = crypto.randomUUID();
|
|
354
|
+
await db.batch([
|
|
355
|
+
db
|
|
356
|
+
.prepare('INSERT INTO content_revisions (id, content_id, payload_json, created_at, created_by) VALUES (?, ?, ?, ?, ?)')
|
|
357
|
+
.bind(revisionId, input.contentId, JSON.stringify(snapshot), input.now, input.createdBy ?? null),
|
|
358
|
+
publicationAttemptInsert(db, attemptId, {
|
|
359
|
+
operation: 'publish',
|
|
360
|
+
contentId: input.contentId,
|
|
361
|
+
revisionId,
|
|
362
|
+
previousRevisionId: input.previousRevisionId,
|
|
363
|
+
surfaceNames: input.surfaceNames,
|
|
364
|
+
packageVersion: input.packageVersion,
|
|
365
|
+
now: input.now,
|
|
366
|
+
}),
|
|
367
|
+
]);
|
|
368
|
+
return { revisionId, snapshot, attempt: await getPublicationAttempt(db, attemptId) };
|
|
369
|
+
}
|
|
370
|
+
function assertPublicationSnapshotBodyValid(snapshot, contentId) {
|
|
371
|
+
const item = snapshot.item;
|
|
372
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
373
|
+
return;
|
|
374
|
+
const type = item.type;
|
|
375
|
+
if (type !== 'article' && type !== 'newsletter' && type !== 'page')
|
|
376
|
+
return;
|
|
377
|
+
const content = snapshot.content;
|
|
378
|
+
const body = content && typeof content === 'object' && !Array.isArray(content) ? content : {};
|
|
379
|
+
const bodyMarkdown = body.body_markdown;
|
|
380
|
+
const bodyHtml = body.body_html;
|
|
381
|
+
const outcome = evaluateArticleBody({
|
|
382
|
+
body_markdown: typeof bodyMarkdown === 'string' ? bodyMarkdown : null,
|
|
383
|
+
body_html: typeof bodyHtml === 'string' ? bodyHtml : null,
|
|
384
|
+
}, {
|
|
385
|
+
source: 'publish-one',
|
|
386
|
+
contentId,
|
|
387
|
+
requireBody: true,
|
|
388
|
+
});
|
|
389
|
+
if (!outcome.ok)
|
|
390
|
+
throw new PublishContentValidationError(outcome);
|
|
391
|
+
}
|
|
392
|
+
function normalizeSurfaces(input) {
|
|
393
|
+
if (!Array.isArray(input.surfaces)) {
|
|
394
|
+
throw new PublicationSurfaceRequirementError('publication surfaces must be provided explicitly; pass allowNoSurfaces for no-live-surface mode');
|
|
395
|
+
}
|
|
396
|
+
if (input.surfaces.length === 0) {
|
|
397
|
+
if (input.allowNoSurfaces)
|
|
398
|
+
return [];
|
|
399
|
+
throw new PublicationSurfaceRequirementError('publication surfaces cannot be empty unless allowNoSurfaces is true');
|
|
400
|
+
}
|
|
401
|
+
const names = new Set();
|
|
402
|
+
for (const surface of input.surfaces) {
|
|
403
|
+
const name = surface.name?.trim();
|
|
404
|
+
if (!name)
|
|
405
|
+
throw new PublicationSurfaceRequirementError('publication surface names are required');
|
|
406
|
+
if (surface.name !== name) {
|
|
407
|
+
throw new PublicationSurfaceRequirementError('publication surface names must not contain surrounding whitespace');
|
|
408
|
+
}
|
|
409
|
+
if (names.has(name)) {
|
|
410
|
+
throw new PublicationSurfaceRequirementError(`duplicate publication surface: ${name}`);
|
|
411
|
+
}
|
|
412
|
+
if (typeof surface.prepare !== 'function') {
|
|
413
|
+
throw new PublicationSurfaceRequirementError(`publication surface ${name} must implement prepare`);
|
|
414
|
+
}
|
|
415
|
+
if (typeof surface.commit !== 'function') {
|
|
416
|
+
throw new PublicationSurfaceRequirementError(`publication surface ${name} must implement commit`);
|
|
417
|
+
}
|
|
418
|
+
names.add(name);
|
|
419
|
+
}
|
|
420
|
+
for (const requiredName of input.requiredSurfaceNames ?? []) {
|
|
421
|
+
if (!names.has(requiredName)) {
|
|
422
|
+
throw new PublicationSurfaceRequirementError(`required publication surface missing: ${requiredName}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return input.surfaces;
|
|
426
|
+
}
|
|
427
|
+
function normalizeRetrySurfaces(surfaces, pendingSurfaceNames) {
|
|
428
|
+
if (pendingSurfaceNames.length === 0) {
|
|
429
|
+
if (surfaces && surfaces.length > 0) {
|
|
430
|
+
throw new PublicationSurfaceRequirementError('completed publication attempts cannot accept retry surfaces');
|
|
431
|
+
}
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
const normalized = normalizeSurfaces({
|
|
435
|
+
surfaces,
|
|
436
|
+
requiredSurfaceNames: pendingSurfaceNames,
|
|
437
|
+
});
|
|
438
|
+
const pending = new Set(pendingSurfaceNames);
|
|
439
|
+
for (const surface of normalized) {
|
|
440
|
+
if (!pending.has(surface.name)) {
|
|
441
|
+
throw new PublicationSurfaceRequirementError(`publication retry surface is not pending: ${surface.name}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return normalized;
|
|
445
|
+
}
|
|
446
|
+
function withPhase(input, phase, signal) {
|
|
447
|
+
return { ...input, phase, signal };
|
|
448
|
+
}
|
|
449
|
+
class PublicationSurfaceHookTimeoutError extends Error {
|
|
450
|
+
constructor() {
|
|
451
|
+
super('publication surface hook timed out');
|
|
452
|
+
this.name = 'PublicationSurfaceHookTimeoutError';
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function runSurfaceHook(hook, input, phase, timeoutMs) {
|
|
456
|
+
const controller = new AbortController();
|
|
457
|
+
let timeoutId;
|
|
458
|
+
const timeout = new Promise((_, reject) => {
|
|
459
|
+
timeoutId = setTimeout(() => {
|
|
460
|
+
controller.abort('publication_surface_hook_timeout');
|
|
461
|
+
reject(new PublicationSurfaceHookTimeoutError());
|
|
462
|
+
}, timeoutMs);
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
return await Promise.race([hook(withPhase(input, phase, controller.signal)), timeout]);
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
if (timeoutId !== undefined)
|
|
469
|
+
clearTimeout(timeoutId);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function normalizeReceipt(surface, phase, receipt, started) {
|
|
473
|
+
const durationMs = Date.now() - started;
|
|
474
|
+
if (receipt === undefined) {
|
|
475
|
+
return { name: surface.name, status: 'ok', phase, durationMs };
|
|
476
|
+
}
|
|
477
|
+
if (typeof receipt !== 'object' || receipt === null) {
|
|
478
|
+
return {
|
|
479
|
+
name: surface.name,
|
|
480
|
+
status: 'failed',
|
|
481
|
+
phase,
|
|
482
|
+
message: durableDiagnostic('invalid_surface_receipt_object'),
|
|
483
|
+
durationMs,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (receipt.status !== 'ok' && receipt.status !== 'skipped' && receipt.status !== 'failed') {
|
|
487
|
+
return {
|
|
488
|
+
name: surface.name,
|
|
489
|
+
status: 'failed',
|
|
490
|
+
phase,
|
|
491
|
+
message: durableDiagnostic('invalid surface receipt status'),
|
|
492
|
+
durationMs,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (receipt.name !== surface.name) {
|
|
496
|
+
return {
|
|
497
|
+
name: surface.name,
|
|
498
|
+
status: 'failed',
|
|
499
|
+
phase,
|
|
500
|
+
message: durableDiagnostic('invalid_surface_receipt_name'),
|
|
501
|
+
durationMs,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (receipt.message !== undefined && typeof receipt.message !== 'string') {
|
|
505
|
+
return {
|
|
506
|
+
name: surface.name,
|
|
507
|
+
status: 'failed',
|
|
508
|
+
phase,
|
|
509
|
+
message: durableDiagnostic('invalid_surface_receipt_message'),
|
|
510
|
+
durationMs,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const suppliedDuration = receipt.durationMs;
|
|
514
|
+
if (suppliedDuration !== undefined &&
|
|
515
|
+
(typeof suppliedDuration !== 'number' ||
|
|
516
|
+
!Number.isFinite(suppliedDuration) ||
|
|
517
|
+
suppliedDuration < 0)) {
|
|
518
|
+
return {
|
|
519
|
+
name: surface.name,
|
|
520
|
+
status: 'failed',
|
|
521
|
+
phase,
|
|
522
|
+
message: durableDiagnostic('invalid_surface_receipt_duration'),
|
|
523
|
+
durationMs,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
name: surface.name,
|
|
528
|
+
status: receipt.status,
|
|
529
|
+
phase,
|
|
530
|
+
message: receipt.status === 'failed'
|
|
531
|
+
? durableDiagnostic('surface_hook_failed')
|
|
532
|
+
: receipt.status === 'skipped'
|
|
533
|
+
? durableDiagnostic('surface_hook_skipped')
|
|
534
|
+
: undefined,
|
|
535
|
+
durationMs: suppliedDuration ?? durationMs,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async function prepareSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutMs) {
|
|
539
|
+
let durable = attempt;
|
|
540
|
+
let receipts = [...durable.receipts];
|
|
541
|
+
let preparedNames = new Set(durable.preparedSurfaceNames);
|
|
542
|
+
surfaceLoop: for (const surface of surfaces) {
|
|
543
|
+
if (preparedNames.has(surface.name))
|
|
544
|
+
continue;
|
|
545
|
+
if (durable.state !== 'preparing') {
|
|
546
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
547
|
+
}
|
|
548
|
+
const started = Date.now();
|
|
549
|
+
let normalized;
|
|
550
|
+
try {
|
|
551
|
+
normalized = normalizeReceipt(surface, 'prepare', await runSurfaceHook(surface.prepare, input, 'prepare', surfaceHookTimeoutMs), started);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
normalized = {
|
|
555
|
+
name: surface.name,
|
|
556
|
+
status: 'failed',
|
|
557
|
+
phase: 'prepare',
|
|
558
|
+
message: durableDiagnostic(error instanceof PublicationSurfaceHookTimeoutError
|
|
559
|
+
? 'surface_hook_timed_out'
|
|
560
|
+
: 'surface_hook_threw'),
|
|
561
|
+
durationMs: Date.now() - started,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
receipts = [...durable.receipts, normalized];
|
|
565
|
+
if (normalized.status !== 'ok') {
|
|
566
|
+
let cleanupNames = new Set([...preparedNames, surface.name]);
|
|
567
|
+
let claimed;
|
|
568
|
+
for (;;) {
|
|
569
|
+
try {
|
|
570
|
+
claimed = await persistPublicationAttempt(db, {
|
|
571
|
+
attemptId: durable.id,
|
|
572
|
+
expectedVersion: durable.version,
|
|
573
|
+
expectedStates: ['preparing'],
|
|
574
|
+
state: 'aborting',
|
|
575
|
+
pendingSurfaceNames: [],
|
|
576
|
+
preparedSurfaceNames: [...cleanupNames],
|
|
577
|
+
receipts,
|
|
578
|
+
lastError: durableDiagnostic('surface_prepare_failed'),
|
|
579
|
+
now: input.now,
|
|
580
|
+
});
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
585
|
+
throw error;
|
|
586
|
+
durable = error.current;
|
|
587
|
+
if (durable.preparedSurfaceNames.includes(surface.name)) {
|
|
588
|
+
receipts = [...durable.receipts];
|
|
589
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
590
|
+
continue surfaceLoop;
|
|
591
|
+
}
|
|
592
|
+
if (durable.state !== 'preparing') {
|
|
593
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
594
|
+
}
|
|
595
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
596
|
+
cleanupNames = new Set([...preparedNames, surface.name]);
|
|
597
|
+
receipts = [...durable.receipts, normalized];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const prepared = surfaces.filter((candidate) => claimed.preparedSurfaceNames.includes(candidate.name));
|
|
601
|
+
const abortReceipts = await abortSurfaces(prepared, input, surfaceHookTimeoutMs);
|
|
602
|
+
const allReceipts = [...receipts, ...abortReceipts];
|
|
603
|
+
const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok');
|
|
604
|
+
const lastError = incompleteAborts.length > 0
|
|
605
|
+
? abortFailureDiagnostic(incompleteAborts, 'surface_abort_incomplete')
|
|
606
|
+
: durableDiagnostic('surface_prepare_failed');
|
|
607
|
+
let finalized;
|
|
608
|
+
try {
|
|
609
|
+
finalized = await persistPublicationAttempt(db, {
|
|
610
|
+
attemptId: claimed.id,
|
|
611
|
+
expectedVersion: claimed.version,
|
|
612
|
+
expectedStates: ['aborting'],
|
|
613
|
+
state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
|
|
614
|
+
pendingSurfaceNames: [],
|
|
615
|
+
preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
|
|
616
|
+
receipts: allReceipts,
|
|
617
|
+
lastError,
|
|
618
|
+
now: input.now,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
623
|
+
throw error;
|
|
624
|
+
finalized = error.current;
|
|
625
|
+
}
|
|
626
|
+
throw new PublicationReconciliationError(`publication surface did not prepare: ${surface.name}`, finalized.receipts, finalized.id);
|
|
627
|
+
}
|
|
628
|
+
preparedNames.add(surface.name);
|
|
629
|
+
for (;;) {
|
|
630
|
+
try {
|
|
631
|
+
durable = await persistPublicationAttempt(db, {
|
|
632
|
+
attemptId: durable.id,
|
|
633
|
+
expectedVersion: durable.version,
|
|
634
|
+
expectedStates: ['preparing'],
|
|
635
|
+
state: 'preparing',
|
|
636
|
+
pendingSurfaceNames: durable.pendingSurfaceNames,
|
|
637
|
+
preparedSurfaceNames: [...preparedNames],
|
|
638
|
+
receipts,
|
|
639
|
+
now: input.now,
|
|
640
|
+
});
|
|
641
|
+
receipts = [...durable.receipts];
|
|
642
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
647
|
+
throw error;
|
|
648
|
+
durable = error.current;
|
|
649
|
+
if (durable.preparedSurfaceNames.includes(surface.name)) {
|
|
650
|
+
receipts = [...durable.receipts];
|
|
651
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
if (durable.state !== 'preparing') {
|
|
655
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
656
|
+
}
|
|
657
|
+
preparedNames = new Set([...durable.preparedSurfaceNames, surface.name]);
|
|
658
|
+
receipts = [...durable.receipts, normalized];
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (;;) {
|
|
663
|
+
try {
|
|
664
|
+
durable = await persistPublicationAttempt(db, {
|
|
665
|
+
attemptId: durable.id,
|
|
666
|
+
expectedVersion: durable.version,
|
|
667
|
+
expectedStates: ['preparing'],
|
|
668
|
+
state: 'prepared',
|
|
669
|
+
pendingSurfaceNames: durable.surfaceNames,
|
|
670
|
+
preparedSurfaceNames: [...preparedNames],
|
|
671
|
+
receipts,
|
|
672
|
+
now: input.now,
|
|
673
|
+
});
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
678
|
+
throw error;
|
|
679
|
+
durable = error.current;
|
|
680
|
+
if (['prepared', 'committing', 'pending_retry', 'committed'].includes(durable.state))
|
|
681
|
+
break;
|
|
682
|
+
if (durable.state !== 'preparing') {
|
|
683
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
684
|
+
}
|
|
685
|
+
receipts = [...durable.receipts];
|
|
686
|
+
preparedNames = new Set(durable.preparedSurfaceNames);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const prepared = surfaces.filter((surface) => durable.preparedSurfaceNames.includes(surface.name));
|
|
690
|
+
return { attempt: durable, receipts: durable.receipts, prepared };
|
|
691
|
+
}
|
|
692
|
+
async function commitSurfaces(db, attempt, surfaces, input, surfaceHookTimeoutMs) {
|
|
693
|
+
let durable = attempt;
|
|
694
|
+
let receipts = [...durable.receipts];
|
|
695
|
+
let pending = new Set(durable.pendingSurfaceNames);
|
|
696
|
+
for (const surface of surfaces) {
|
|
697
|
+
if (!pending.has(surface.name))
|
|
698
|
+
continue;
|
|
699
|
+
const started = Date.now();
|
|
700
|
+
let normalized;
|
|
701
|
+
try {
|
|
702
|
+
normalized = normalizeReceipt(surface, 'commit', await runSurfaceHook(surface.commit, input, 'commit', surfaceHookTimeoutMs), started);
|
|
703
|
+
if (normalized.status === 'skipped')
|
|
704
|
+
normalized.status = 'failed';
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
normalized = {
|
|
708
|
+
name: surface.name,
|
|
709
|
+
status: 'failed',
|
|
710
|
+
phase: 'commit',
|
|
711
|
+
message: durableDiagnostic(error instanceof PublicationSurfaceHookTimeoutError
|
|
712
|
+
? 'surface_hook_timed_out'
|
|
713
|
+
: 'surface_hook_threw'),
|
|
714
|
+
durationMs: Date.now() - started,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
for (;;) {
|
|
718
|
+
receipts = [...durable.receipts, normalized];
|
|
719
|
+
pending = new Set(durable.pendingSurfaceNames);
|
|
720
|
+
if (normalized.status === 'ok')
|
|
721
|
+
pending.delete(surface.name);
|
|
722
|
+
const inProgressState = durable.state === 'pending_retry' ? 'pending_retry' : 'committing';
|
|
723
|
+
if (!['committing', 'pending_retry'].includes(durable.state)) {
|
|
724
|
+
if (durable.state === 'committed') {
|
|
725
|
+
return {
|
|
726
|
+
attempt: durable,
|
|
727
|
+
receipts: durable.receipts,
|
|
728
|
+
pendingSurfaceNames: durable.pendingSurfaceNames,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
durable = await persistPublicationAttempt(db, {
|
|
735
|
+
attemptId: durable.id,
|
|
736
|
+
expectedVersion: durable.version,
|
|
737
|
+
expectedStates: [durable.state],
|
|
738
|
+
state: inProgressState,
|
|
739
|
+
pendingSurfaceNames: [...pending],
|
|
740
|
+
preparedSurfaceNames: durable.preparedSurfaceNames,
|
|
741
|
+
receipts,
|
|
742
|
+
lastError: normalized.status === 'failed' ? durableDiagnostic('surface_commit_failed') : null,
|
|
743
|
+
now: input.now,
|
|
744
|
+
});
|
|
745
|
+
receipts = [...durable.receipts];
|
|
746
|
+
pending = new Set(durable.pendingSurfaceNames);
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
751
|
+
throw error;
|
|
752
|
+
durable = error.current;
|
|
753
|
+
if (durable.state === 'committed' || !durable.pendingSurfaceNames.includes(surface.name)) {
|
|
754
|
+
receipts = [...durable.receipts];
|
|
755
|
+
pending = new Set(durable.pendingSurfaceNames);
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (;;) {
|
|
762
|
+
if (durable.state === 'committed')
|
|
763
|
+
break;
|
|
764
|
+
if (!['committing', 'pending_retry'].includes(durable.state)) {
|
|
765
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${durable.state}`, durable.receipts, durable.id);
|
|
766
|
+
}
|
|
767
|
+
pending = new Set(durable.pendingSurfaceNames);
|
|
768
|
+
const pendingFailures = durable.receipts.filter((receipt) => receipt.phase === 'commit' && receipt.status === 'failed' && pending.has(receipt.name));
|
|
769
|
+
try {
|
|
770
|
+
durable = await persistPublicationAttempt(db, {
|
|
771
|
+
attemptId: durable.id,
|
|
772
|
+
expectedVersion: durable.version,
|
|
773
|
+
expectedStates: [durable.state],
|
|
774
|
+
state: pending.size > 0 ? 'pending_retry' : 'committed',
|
|
775
|
+
pendingSurfaceNames: [...pending],
|
|
776
|
+
preparedSurfaceNames: durable.preparedSurfaceNames,
|
|
777
|
+
receipts: durable.receipts,
|
|
778
|
+
lastError: pendingFailures.length > 0 ? durableDiagnostic('surface_commit_failed') : null,
|
|
779
|
+
now: input.now,
|
|
780
|
+
});
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
785
|
+
throw error;
|
|
786
|
+
durable = error.current;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
attempt: durable,
|
|
791
|
+
receipts: durable.receipts,
|
|
792
|
+
pendingSurfaceNames: durable.pendingSurfaceNames,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
async function abortSurfaces(surfaces, input, surfaceHookTimeoutMs) {
|
|
796
|
+
const receipts = [];
|
|
797
|
+
for (const surface of [...surfaces].reverse()) {
|
|
798
|
+
if (!surface.abort) {
|
|
799
|
+
receipts.push({
|
|
800
|
+
name: surface.name,
|
|
801
|
+
status: 'skipped',
|
|
802
|
+
phase: 'abort',
|
|
803
|
+
message: durableDiagnostic('abort_hook_not_implemented'),
|
|
804
|
+
});
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const started = Date.now();
|
|
808
|
+
try {
|
|
809
|
+
receipts.push(normalizeReceipt(surface, 'abort', await runSurfaceHook(surface.abort, input, 'abort', surfaceHookTimeoutMs), started));
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
receipts.push({
|
|
813
|
+
name: surface.name,
|
|
814
|
+
status: 'failed',
|
|
815
|
+
phase: 'abort',
|
|
816
|
+
message: durableDiagnostic(error instanceof PublicationSurfaceHookTimeoutError
|
|
817
|
+
? 'surface_hook_timed_out'
|
|
818
|
+
: 'surface_hook_threw'),
|
|
819
|
+
durationMs: Date.now() - started,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return receipts;
|
|
824
|
+
}
|
|
825
|
+
function abortFailureDiagnostic(receipts, fallback) {
|
|
826
|
+
if (receipts.some((receipt) => receipt.message === 'abort_hook_not_implemented')) {
|
|
827
|
+
return durableDiagnostic('abort hook not implemented');
|
|
828
|
+
}
|
|
829
|
+
if (receipts.some((receipt) => receipt.message === 'surface_hook_failed')) {
|
|
830
|
+
return durableDiagnostic('surface abort failed');
|
|
831
|
+
}
|
|
832
|
+
return durableDiagnostic(fallback);
|
|
833
|
+
}
|
|
834
|
+
async function retryAbortCleanup(db, attempt, surfaces, input, surfaceHookTimeoutMs) {
|
|
835
|
+
let claimed;
|
|
836
|
+
try {
|
|
837
|
+
claimed = await persistPublicationAttempt(db, {
|
|
838
|
+
attemptId: attempt.id,
|
|
839
|
+
expectedVersion: attempt.version,
|
|
840
|
+
expectedStates: [attempt.state],
|
|
841
|
+
state: 'aborting',
|
|
842
|
+
pendingSurfaceNames: [],
|
|
843
|
+
preparedSurfaceNames: attempt.preparedSurfaceNames,
|
|
844
|
+
receipts: attempt.receipts,
|
|
845
|
+
lastError: attempt.lastError,
|
|
846
|
+
now: input.now,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
851
|
+
throw error;
|
|
852
|
+
if (error.current.state === 'aborted') {
|
|
853
|
+
return {
|
|
854
|
+
status: 'aborted',
|
|
855
|
+
attemptId: error.current.id,
|
|
856
|
+
contentId: error.current.contentId,
|
|
857
|
+
revisionId: error.current.revisionId,
|
|
858
|
+
previousRevisionId: error.current.previousRevisionId,
|
|
859
|
+
surfaceStatus: 'none',
|
|
860
|
+
surfaces: error.current.receipts,
|
|
861
|
+
packageVersion: error.current.packageVersion,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
throw new PublicationReconciliationError(`publication cleanup advanced concurrently to ${error.current.state}`, error.current.receipts, error.current.id);
|
|
865
|
+
}
|
|
866
|
+
const preparedNames = new Set(claimed.preparedSurfaceNames);
|
|
867
|
+
const abortReceipts = await abortSurfaces(surfaces.filter((surface) => preparedNames.has(surface.name)), input, surfaceHookTimeoutMs);
|
|
868
|
+
const receipts = [...claimed.receipts, ...abortReceipts];
|
|
869
|
+
const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok');
|
|
870
|
+
const lastError = incompleteAborts.length > 0
|
|
871
|
+
? abortFailureDiagnostic(incompleteAborts, 'surface_abort_incomplete')
|
|
872
|
+
: null;
|
|
873
|
+
let finalized;
|
|
874
|
+
try {
|
|
875
|
+
finalized = await persistPublicationAttempt(db, {
|
|
876
|
+
attemptId: claimed.id,
|
|
877
|
+
expectedVersion: claimed.version,
|
|
878
|
+
expectedStates: ['aborting'],
|
|
879
|
+
state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
|
|
880
|
+
pendingSurfaceNames: [],
|
|
881
|
+
preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
|
|
882
|
+
receipts,
|
|
883
|
+
lastError,
|
|
884
|
+
now: input.now,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
889
|
+
throw error;
|
|
890
|
+
if (!['aborted', 'abort_failed'].includes(error.current.state)) {
|
|
891
|
+
throw new PublicationReconciliationError(`publication cleanup advanced concurrently to ${error.current.state}`, error.current.receipts, error.current.id);
|
|
892
|
+
}
|
|
893
|
+
finalized = error.current;
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
status: finalized.state === 'aborted' ? 'aborted' : 'abort_failed',
|
|
897
|
+
attemptId: finalized.id,
|
|
898
|
+
contentId: finalized.contentId,
|
|
899
|
+
revisionId: finalized.revisionId,
|
|
900
|
+
previousRevisionId: finalized.previousRevisionId,
|
|
901
|
+
surfaceStatus: 'none',
|
|
902
|
+
surfaces: finalized.receipts,
|
|
903
|
+
packageVersion: finalized.packageVersion,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function surfaceStatusFor(surfaceNames, pendingSurfaceNames) {
|
|
907
|
+
if (surfaceNames.length === 0)
|
|
908
|
+
return 'none';
|
|
909
|
+
return pendingSurfaceNames.length > 0
|
|
910
|
+
? 'pending_retry'
|
|
911
|
+
: 'committed';
|
|
912
|
+
}
|
|
913
|
+
async function assertRevisionWasCanonical(db, contentId, revisionId) {
|
|
914
|
+
const provenance = await db
|
|
915
|
+
.prepare(`SELECT id
|
|
916
|
+
FROM content_publication_attempts
|
|
917
|
+
WHERE content_id = ? AND revision_id = ?
|
|
918
|
+
AND state IN ('committing', 'pending_retry', 'committed', 'superseded')
|
|
919
|
+
LIMIT 1`)
|
|
920
|
+
.bind(contentId, revisionId)
|
|
921
|
+
.first();
|
|
922
|
+
if (!provenance) {
|
|
923
|
+
throw new PublicationNotFoundError(`rollback target revision never became canonical: ${revisionId}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function supersedePublicationAttempt(db, attempt, now) {
|
|
927
|
+
let durable = attempt;
|
|
928
|
+
for (;;) {
|
|
929
|
+
if (durable.state === 'superseded' || durable.state === 'committed')
|
|
930
|
+
return durable;
|
|
931
|
+
if (!['committing', 'pending_retry'].includes(durable.state)) {
|
|
932
|
+
throw new PublicationReconciliationError(`publication attempt cannot be superseded from state ${durable.state}`, durable.receipts, durable.id);
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
return await persistPublicationAttempt(db, {
|
|
936
|
+
attemptId: durable.id,
|
|
937
|
+
expectedVersion: durable.version,
|
|
938
|
+
expectedStates: [durable.state],
|
|
939
|
+
state: 'superseded',
|
|
940
|
+
pendingSurfaceNames: [],
|
|
941
|
+
preparedSurfaceNames: durable.preparedSurfaceNames,
|
|
942
|
+
receipts: durable.receipts,
|
|
943
|
+
lastError: durableDiagnostic('canonical_pointer_advanced'),
|
|
944
|
+
now,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
949
|
+
throw error;
|
|
950
|
+
durable = error.current;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
async function claimPrecanonicalAttemptForCleanup(db, attempt, now) {
|
|
955
|
+
let durable = attempt;
|
|
956
|
+
for (;;) {
|
|
957
|
+
if (durable.state === 'aborting' || durable.state === 'abort_failed')
|
|
958
|
+
return durable;
|
|
959
|
+
if (!['preparing', 'prepared'].includes(durable.state)) {
|
|
960
|
+
throw new PublicationReconciliationError(`publication attempt cannot enter stale cleanup from state ${durable.state}`, durable.receipts, durable.id);
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
return await persistPublicationAttempt(db, {
|
|
964
|
+
attemptId: durable.id,
|
|
965
|
+
expectedVersion: durable.version,
|
|
966
|
+
expectedStates: [durable.state],
|
|
967
|
+
state: 'aborting',
|
|
968
|
+
pendingSurfaceNames: [],
|
|
969
|
+
preparedSurfaceNames: durable.preparedSurfaceNames,
|
|
970
|
+
receipts: durable.receipts,
|
|
971
|
+
lastError: durableDiagnostic('canonical_pointer_advanced'),
|
|
972
|
+
now,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
if (!(error instanceof PublicationAttemptConflictError))
|
|
977
|
+
throw error;
|
|
978
|
+
durable = error.current;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async function movePointerAndBeginCommit(db, attempt, now) {
|
|
983
|
+
const pointerStatement = attempt.operation === 'publish'
|
|
984
|
+
? db
|
|
985
|
+
.prepare(`UPDATE content_items
|
|
986
|
+
SET status = 'published', published_at = ?, publish_at = NULL,
|
|
987
|
+
published_revision_id = ?, updated_at = unixepoch()
|
|
988
|
+
WHERE id = ?
|
|
989
|
+
AND COALESCE(published_revision_id, '') = ?
|
|
990
|
+
AND EXISTS (
|
|
991
|
+
SELECT 1 FROM content_publication_attempts
|
|
992
|
+
WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
|
|
993
|
+
)`)
|
|
994
|
+
.bind(now, attempt.revisionId, attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId)
|
|
995
|
+
: db
|
|
996
|
+
.prepare(`UPDATE content_items
|
|
997
|
+
SET status = 'published', publish_at = NULL,
|
|
998
|
+
published_revision_id = ?, updated_at = unixepoch()
|
|
999
|
+
WHERE id = ?
|
|
1000
|
+
AND COALESCE(published_revision_id, '') = ?
|
|
1001
|
+
AND EXISTS (
|
|
1002
|
+
SELECT 1 FROM content_publication_attempts
|
|
1003
|
+
WHERE id = ? AND state = 'prepared' AND revision_id = ? AND content_id = ?
|
|
1004
|
+
)`)
|
|
1005
|
+
.bind(attempt.revisionId, attempt.contentId, attempt.previousRevisionId ?? '', attempt.id, attempt.revisionId, attempt.contentId);
|
|
1006
|
+
const attemptStatement = db
|
|
1007
|
+
.prepare(`UPDATE content_publication_attempts
|
|
1008
|
+
SET state = 'committing', updated_at = ?, version = version + 1
|
|
1009
|
+
WHERE id = ? AND state = 'prepared' AND version = ?
|
|
1010
|
+
AND EXISTS (
|
|
1011
|
+
SELECT 1 FROM content_items
|
|
1012
|
+
WHERE id = ? AND published_revision_id = ?
|
|
1013
|
+
)`)
|
|
1014
|
+
.bind(now, attempt.id, attempt.version, attempt.contentId, attempt.revisionId);
|
|
1015
|
+
const [pointerResult, attemptResult] = await db.batch([pointerStatement, attemptStatement]);
|
|
1016
|
+
const pointerChanges = pointerResult ? d1Changes(pointerResult) : 0;
|
|
1017
|
+
const attemptChanges = attemptResult ? d1Changes(attemptResult) : 0;
|
|
1018
|
+
const [item, durableAttempt] = await Promise.all([
|
|
1019
|
+
getContentItem(db, attempt.contentId),
|
|
1020
|
+
getPublicationAttempt(db, attempt.id),
|
|
1021
|
+
]);
|
|
1022
|
+
const canonicalMatches = item?.published_revision_id === attempt.revisionId;
|
|
1023
|
+
if (canonicalMatches &&
|
|
1024
|
+
['committing', 'pending_retry', 'committed'].includes(durableAttempt.state)) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (!canonicalMatches && durableAttempt.state === 'committed')
|
|
1028
|
+
return;
|
|
1029
|
+
if (!canonicalMatches && ['committing', 'pending_retry'].includes(durableAttempt.state)) {
|
|
1030
|
+
const superseded = await supersedePublicationAttempt(db, durableAttempt, now);
|
|
1031
|
+
throw new PublicationAttemptSupersededError(superseded);
|
|
1032
|
+
}
|
|
1033
|
+
if (durableAttempt.state === 'superseded') {
|
|
1034
|
+
throw new PublicationAttemptSupersededError(durableAttempt);
|
|
1035
|
+
}
|
|
1036
|
+
if (canonicalMatches && durableAttempt.state === 'prepared') {
|
|
1037
|
+
await persistPublicationAttempt(db, {
|
|
1038
|
+
attemptId: durableAttempt.id,
|
|
1039
|
+
expectedVersion: durableAttempt.version,
|
|
1040
|
+
expectedStates: ['prepared'],
|
|
1041
|
+
state: 'committing',
|
|
1042
|
+
pendingSurfaceNames: durableAttempt.pendingSurfaceNames,
|
|
1043
|
+
preparedSurfaceNames: durableAttempt.preparedSurfaceNames,
|
|
1044
|
+
receipts: durableAttempt.receipts,
|
|
1045
|
+
now,
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if ((pointerChanges !== null && pointerChanges !== 1) ||
|
|
1050
|
+
(attemptChanges !== null && attemptChanges !== 1)) {
|
|
1051
|
+
throw new PublicationPointerMoveError(`canonical pointer changed before ${attempt.operation} move`, attempt.receipts, attempt.id);
|
|
1052
|
+
}
|
|
1053
|
+
if (!canonicalMatches || durableAttempt.state !== 'committing') {
|
|
1054
|
+
throw new PublicationPointerMoveError(`canonical pointer and durable attempt did not begin ${attempt.operation} commit`, attempt.receipts, attempt.id);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
async function abortAfterPointerFailure(db, attempt, preparedSurfaces, reconciliationInput, receipts, surfaceHookTimeoutMs) {
|
|
1058
|
+
let claimed;
|
|
1059
|
+
try {
|
|
1060
|
+
claimed = await persistPublicationAttempt(db, {
|
|
1061
|
+
attemptId: attempt.id,
|
|
1062
|
+
expectedVersion: attempt.version,
|
|
1063
|
+
expectedStates: ['prepared'],
|
|
1064
|
+
state: 'aborting',
|
|
1065
|
+
pendingSurfaceNames: [],
|
|
1066
|
+
preparedSurfaceNames: attempt.preparedSurfaceNames,
|
|
1067
|
+
receipts,
|
|
1068
|
+
lastError: durableDiagnostic('canonical_pointer_move_failed'),
|
|
1069
|
+
now: reconciliationInput.now,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
catch (claimError) {
|
|
1073
|
+
if (!(claimError instanceof PublicationAttemptConflictError))
|
|
1074
|
+
throw claimError;
|
|
1075
|
+
if (['committing', 'pending_retry', 'committed', 'superseded'].includes(claimError.current.state)) {
|
|
1076
|
+
throw new PublicationAttemptSupersededError(claimError.current);
|
|
1077
|
+
}
|
|
1078
|
+
throw new PublicationReconciliationError(`publication attempt advanced concurrently to ${claimError.current.state}`, claimError.current.receipts, claimError.current.id);
|
|
1079
|
+
}
|
|
1080
|
+
const preparedNames = new Set(claimed.preparedSurfaceNames);
|
|
1081
|
+
const abortReceipts = await abortSurfaces(preparedSurfaces.filter((surface) => preparedNames.has(surface.name)), reconciliationInput, surfaceHookTimeoutMs);
|
|
1082
|
+
const allReceipts = [...receipts, ...abortReceipts];
|
|
1083
|
+
const incompleteAborts = abortReceipts.filter((receipt) => receipt.status !== 'ok');
|
|
1084
|
+
const lastError = durableDiagnostic(incompleteAborts.length > 0
|
|
1085
|
+
? 'canonical_pointer_move_abort_incomplete'
|
|
1086
|
+
: 'canonical_pointer_move_failed');
|
|
1087
|
+
let finalized;
|
|
1088
|
+
try {
|
|
1089
|
+
finalized = await persistPublicationAttempt(db, {
|
|
1090
|
+
attemptId: claimed.id,
|
|
1091
|
+
expectedVersion: claimed.version,
|
|
1092
|
+
expectedStates: ['aborting'],
|
|
1093
|
+
state: incompleteAborts.length > 0 ? 'abort_failed' : 'aborted',
|
|
1094
|
+
pendingSurfaceNames: [],
|
|
1095
|
+
preparedSurfaceNames: incompleteAborts.map((receipt) => receipt.name),
|
|
1096
|
+
receipts: allReceipts,
|
|
1097
|
+
lastError,
|
|
1098
|
+
now: reconciliationInput.now,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
catch (finalizeError) {
|
|
1102
|
+
if (!(finalizeError instanceof PublicationAttemptConflictError))
|
|
1103
|
+
throw finalizeError;
|
|
1104
|
+
finalized = finalizeError.current;
|
|
1105
|
+
}
|
|
1106
|
+
throw new PublicationPointerMoveError(`canonical pointer did not move for ${finalized.operation}`, finalized.receipts, finalized.id);
|
|
1107
|
+
}
|
|
1108
|
+
export async function publishOne(db, input) {
|
|
1109
|
+
const now = input.now ?? nowSeconds();
|
|
1110
|
+
const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs);
|
|
1111
|
+
const surfaces = normalizeSurfaces(input);
|
|
1112
|
+
const item = await getContentItem(db, input.contentId);
|
|
1113
|
+
if (!item)
|
|
1114
|
+
throw new PublicationNotFoundError(`content not found: ${input.contentId}`);
|
|
1115
|
+
const previousRevisionId = item.published_revision_id;
|
|
1116
|
+
const { revisionId, snapshot, attempt } = await createRevisionAndPublicationAttempt(db, {
|
|
1117
|
+
contentId: input.contentId,
|
|
1118
|
+
previousRevisionId,
|
|
1119
|
+
surfaceNames: surfaces.map((surface) => surface.name),
|
|
1120
|
+
packageVersion: input.packageVersion ?? null,
|
|
1121
|
+
now,
|
|
1122
|
+
createdBy: input.createdBy,
|
|
1123
|
+
});
|
|
1124
|
+
const reconciliationInput = {
|
|
1125
|
+
operation: 'publish',
|
|
1126
|
+
contentId: input.contentId,
|
|
1127
|
+
revisionId,
|
|
1128
|
+
previousRevisionId,
|
|
1129
|
+
snapshot,
|
|
1130
|
+
now,
|
|
1131
|
+
packageVersion: input.packageVersion ?? null,
|
|
1132
|
+
};
|
|
1133
|
+
const prepared = await prepareSurfaces(db, attempt, surfaces, reconciliationInput, surfaceHookTimeoutMs);
|
|
1134
|
+
const durablePrepared = await getPublicationAttempt(db, attempt.id);
|
|
1135
|
+
try {
|
|
1136
|
+
await movePointerAndBeginCommit(db, durablePrepared, now);
|
|
1137
|
+
}
|
|
1138
|
+
catch (error) {
|
|
1139
|
+
if (error instanceof PublicationAttemptSupersededError)
|
|
1140
|
+
throw error;
|
|
1141
|
+
return abortAfterPointerFailure(db, durablePrepared, prepared.prepared, reconciliationInput, prepared.receipts, surfaceHookTimeoutMs);
|
|
1142
|
+
}
|
|
1143
|
+
const afterMove = await getPublicationAttempt(db, attempt.id);
|
|
1144
|
+
const pendingAfterMove = new Set(afterMove.pendingSurfaceNames);
|
|
1145
|
+
const committed = await commitSurfaces(db, afterMove, surfaces.filter((surface) => pendingAfterMove.has(surface.name)), reconciliationInput, surfaceHookTimeoutMs);
|
|
1146
|
+
const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames);
|
|
1147
|
+
return {
|
|
1148
|
+
status: 'published',
|
|
1149
|
+
attemptId: attempt.id,
|
|
1150
|
+
contentId: input.contentId,
|
|
1151
|
+
revisionId,
|
|
1152
|
+
previousRevisionId,
|
|
1153
|
+
publishedAt: now,
|
|
1154
|
+
surfaceStatus,
|
|
1155
|
+
surfaces: committed.receipts,
|
|
1156
|
+
packageVersion: input.packageVersion ?? null,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
export async function rollbackPublication(db, input) {
|
|
1160
|
+
const now = input.now ?? nowSeconds();
|
|
1161
|
+
const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs);
|
|
1162
|
+
const surfaces = normalizeSurfaces(input);
|
|
1163
|
+
const item = await getContentItem(db, input.contentId);
|
|
1164
|
+
if (!item)
|
|
1165
|
+
throw new PublicationNotFoundError(`content not found: ${input.contentId}`);
|
|
1166
|
+
const target = await getRevision(db, input.contentId, input.targetRevisionId);
|
|
1167
|
+
if (!target) {
|
|
1168
|
+
throw new PublicationNotFoundError(`revision not found: ${input.targetRevisionId}`);
|
|
1169
|
+
}
|
|
1170
|
+
await assertRevisionWasCanonical(db, input.contentId, input.targetRevisionId);
|
|
1171
|
+
const previousRevisionId = item.published_revision_id;
|
|
1172
|
+
const reconciliationInput = {
|
|
1173
|
+
operation: 'rollback',
|
|
1174
|
+
contentId: input.contentId,
|
|
1175
|
+
revisionId: input.targetRevisionId,
|
|
1176
|
+
previousRevisionId,
|
|
1177
|
+
snapshot: target.payload,
|
|
1178
|
+
now,
|
|
1179
|
+
packageVersion: input.packageVersion ?? null,
|
|
1180
|
+
};
|
|
1181
|
+
const attempt = await createPublicationAttempt(db, {
|
|
1182
|
+
operation: 'rollback',
|
|
1183
|
+
contentId: input.contentId,
|
|
1184
|
+
revisionId: input.targetRevisionId,
|
|
1185
|
+
previousRevisionId,
|
|
1186
|
+
surfaceNames: surfaces.map((surface) => surface.name),
|
|
1187
|
+
packageVersion: input.packageVersion ?? null,
|
|
1188
|
+
now,
|
|
1189
|
+
});
|
|
1190
|
+
const prepared = await prepareSurfaces(db, attempt, surfaces, reconciliationInput, surfaceHookTimeoutMs);
|
|
1191
|
+
const durablePrepared = await getPublicationAttempt(db, attempt.id);
|
|
1192
|
+
try {
|
|
1193
|
+
await movePointerAndBeginCommit(db, durablePrepared, now);
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
if (error instanceof PublicationAttemptSupersededError)
|
|
1197
|
+
throw error;
|
|
1198
|
+
return abortAfterPointerFailure(db, durablePrepared, prepared.prepared, reconciliationInput, prepared.receipts, surfaceHookTimeoutMs);
|
|
1199
|
+
}
|
|
1200
|
+
const afterMove = await getPublicationAttempt(db, attempt.id);
|
|
1201
|
+
const pendingAfterMove = new Set(afterMove.pendingSurfaceNames);
|
|
1202
|
+
const committed = await commitSurfaces(db, afterMove, surfaces.filter((surface) => pendingAfterMove.has(surface.name)), reconciliationInput, surfaceHookTimeoutMs);
|
|
1203
|
+
const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames);
|
|
1204
|
+
return {
|
|
1205
|
+
status: 'rolled_back',
|
|
1206
|
+
attemptId: attempt.id,
|
|
1207
|
+
contentId: input.contentId,
|
|
1208
|
+
revisionId: input.targetRevisionId,
|
|
1209
|
+
previousRevisionId,
|
|
1210
|
+
rolledBackAt: now,
|
|
1211
|
+
surfaceStatus,
|
|
1212
|
+
surfaces: committed.receipts,
|
|
1213
|
+
packageVersion: input.packageVersion ?? null,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
export async function retryPublicationSurfaces(db, input) {
|
|
1217
|
+
const now = input.now ?? nowSeconds();
|
|
1218
|
+
const surfaceHookTimeoutMs = normalizeSurfaceHookTimeoutMs(input.surfaceHookTimeoutMs);
|
|
1219
|
+
let attempt = await getPublicationAttempt(db, input.attemptId);
|
|
1220
|
+
if (attempt.state === 'committed') {
|
|
1221
|
+
normalizeRetrySurfaces(input.surfaces, attempt.pendingSurfaceNames);
|
|
1222
|
+
return {
|
|
1223
|
+
status: 'reconciled',
|
|
1224
|
+
attemptId: attempt.id,
|
|
1225
|
+
contentId: attempt.contentId,
|
|
1226
|
+
revisionId: attempt.revisionId,
|
|
1227
|
+
previousRevisionId: attempt.previousRevisionId,
|
|
1228
|
+
surfaceStatus: surfaceStatusFor(attempt.surfaceNames, []),
|
|
1229
|
+
surfaces: attempt.receipts,
|
|
1230
|
+
packageVersion: attempt.packageVersion,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
if (!['preparing', 'prepared', 'committing', 'pending_retry', 'aborting', 'abort_failed'].includes(attempt.state)) {
|
|
1234
|
+
throw new PublicationReconciliationError(`publication attempt is not recoverable from state ${attempt.state}`, attempt.receipts, attempt.id);
|
|
1235
|
+
}
|
|
1236
|
+
const item = await getContentItem(db, attempt.contentId);
|
|
1237
|
+
if (!item)
|
|
1238
|
+
throw new PublicationNotFoundError(`content not found: ${attempt.contentId}`);
|
|
1239
|
+
if (['committing', 'pending_retry'].includes(attempt.state) &&
|
|
1240
|
+
item.published_revision_id !== attempt.revisionId) {
|
|
1241
|
+
attempt = await supersedePublicationAttempt(db, attempt, now);
|
|
1242
|
+
throw new PublicationAttemptSupersededError(attempt);
|
|
1243
|
+
}
|
|
1244
|
+
const stalePrecanonicalAttempt = ['preparing', 'prepared'].includes(attempt.state) &&
|
|
1245
|
+
item.published_revision_id !== attempt.previousRevisionId &&
|
|
1246
|
+
item.published_revision_id !== attempt.revisionId;
|
|
1247
|
+
if (stalePrecanonicalAttempt) {
|
|
1248
|
+
attempt = await claimPrecanonicalAttemptForCleanup(db, attempt, now);
|
|
1249
|
+
}
|
|
1250
|
+
const cleanupRecovery = stalePrecanonicalAttempt || ['aborting', 'abort_failed'].includes(attempt.state);
|
|
1251
|
+
const retrySurfaceInput = stalePrecanonicalAttempt
|
|
1252
|
+
? input.surfaces?.filter((surface) => attempt.preparedSurfaceNames.includes(surface.name))
|
|
1253
|
+
: input.surfaces;
|
|
1254
|
+
const surfaces = normalizeRetrySurfaces(retrySurfaceInput, cleanupRecovery ? attempt.preparedSurfaceNames : attempt.pendingSurfaceNames);
|
|
1255
|
+
const revision = await getRevision(db, attempt.contentId, attempt.revisionId);
|
|
1256
|
+
if (!revision)
|
|
1257
|
+
throw new PublicationNotFoundError(`revision not found: ${attempt.revisionId}`);
|
|
1258
|
+
const reconciliationInput = {
|
|
1259
|
+
operation: attempt.operation,
|
|
1260
|
+
contentId: attempt.contentId,
|
|
1261
|
+
revisionId: attempt.revisionId,
|
|
1262
|
+
previousRevisionId: attempt.previousRevisionId,
|
|
1263
|
+
snapshot: revision.payload,
|
|
1264
|
+
now,
|
|
1265
|
+
packageVersion: attempt.packageVersion,
|
|
1266
|
+
};
|
|
1267
|
+
if (cleanupRecovery) {
|
|
1268
|
+
const cleanup = await retryAbortCleanup(db, attempt, surfaces, reconciliationInput, surfaceHookTimeoutMs);
|
|
1269
|
+
if (stalePrecanonicalAttempt) {
|
|
1270
|
+
throw new PublicationAttemptSupersededError(await getPublicationAttempt(db, cleanup.attemptId));
|
|
1271
|
+
}
|
|
1272
|
+
return cleanup;
|
|
1273
|
+
}
|
|
1274
|
+
if (attempt.state === 'preparing') {
|
|
1275
|
+
const prepared = await prepareSurfaces(db, attempt, surfaces, reconciliationInput, surfaceHookTimeoutMs);
|
|
1276
|
+
attempt = prepared.attempt;
|
|
1277
|
+
try {
|
|
1278
|
+
await movePointerAndBeginCommit(db, attempt, now);
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
if (error instanceof PublicationAttemptSupersededError)
|
|
1282
|
+
throw error;
|
|
1283
|
+
return abortAfterPointerFailure(db, attempt, prepared.prepared, reconciliationInput, prepared.receipts, surfaceHookTimeoutMs);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
else if (attempt.state === 'prepared') {
|
|
1287
|
+
try {
|
|
1288
|
+
await movePointerAndBeginCommit(db, attempt, now);
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
if (error instanceof PublicationAttemptSupersededError)
|
|
1292
|
+
throw error;
|
|
1293
|
+
return abortAfterPointerFailure(db, attempt, surfaces, reconciliationInput, attempt.receipts, surfaceHookTimeoutMs);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
else if (item.published_revision_id !== attempt.revisionId) {
|
|
1297
|
+
throw new PublicationPointerMoveError('canonical pointer does not match durable retry revision', attempt.receipts, attempt.id);
|
|
1298
|
+
}
|
|
1299
|
+
attempt = await getPublicationAttempt(db, attempt.id);
|
|
1300
|
+
const pending = new Set(attempt.pendingSurfaceNames);
|
|
1301
|
+
const committed = await commitSurfaces(db, attempt, surfaces.filter((surface) => pending.has(surface.name)), reconciliationInput, surfaceHookTimeoutMs);
|
|
1302
|
+
const surfaceStatus = surfaceStatusFor(attempt.surfaceNames, committed.pendingSurfaceNames);
|
|
1303
|
+
return {
|
|
1304
|
+
status: surfaceStatus === 'pending_retry' ? 'pending_retry' : 'reconciled',
|
|
1305
|
+
attemptId: attempt.id,
|
|
1306
|
+
contentId: attempt.contentId,
|
|
1307
|
+
revisionId: attempt.revisionId,
|
|
1308
|
+
previousRevisionId: attempt.previousRevisionId,
|
|
1309
|
+
surfaceStatus,
|
|
1310
|
+
surfaces: committed.receipts,
|
|
1311
|
+
packageVersion: attempt.packageVersion,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
function bodyBackedArchiveType(type) {
|
|
1315
|
+
if (type === 'article' || type === 'newsletter' || type === 'page')
|
|
1316
|
+
return type;
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
const DEFAULT_IMPORT_BATCH_ITEMS = 100;
|
|
1320
|
+
const DEFAULT_MAX_IMPORT_BATCH_ITEMS = 500;
|
|
1321
|
+
const DEFAULT_MAX_ARCHIVE_ITEMS = 10_000;
|
|
1322
|
+
const DEFAULT_MAX_ARCHIVE_BODY_BYTES = 256 * 1024;
|
|
1323
|
+
function textBytes(value) {
|
|
1324
|
+
return new TextEncoder().encode(value).byteLength;
|
|
1325
|
+
}
|
|
1326
|
+
function parseArchiveCursor(cursor) {
|
|
1327
|
+
if (cursor === null || cursor === undefined || cursor === '')
|
|
1328
|
+
return 0;
|
|
1329
|
+
const parsed = typeof cursor === 'number' ? cursor : Number(cursor);
|
|
1330
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
1331
|
+
throw new ContentArchiveImportLimitError('archive_cursor_invalid', 'archive import cursor must be a non-negative integer', 0, parsed);
|
|
1332
|
+
}
|
|
1333
|
+
return parsed;
|
|
1334
|
+
}
|
|
1335
|
+
function assertLimit(code, message, limit, actual) {
|
|
1336
|
+
if (actual > limit)
|
|
1337
|
+
throw new ContentArchiveImportLimitError(code, message, limit, actual);
|
|
1338
|
+
}
|
|
1339
|
+
async function findContentBySlug(db, slug) {
|
|
1340
|
+
const row = await db
|
|
1341
|
+
.prepare('SELECT id, slug, type FROM content_items WHERE slug = ? LIMIT 1')
|
|
1342
|
+
.bind(slug)
|
|
1343
|
+
.first();
|
|
1344
|
+
if (!row)
|
|
1345
|
+
return null;
|
|
1346
|
+
const type = bodyBackedArchiveType(row.type);
|
|
1347
|
+
if (!type)
|
|
1348
|
+
return null;
|
|
1349
|
+
return { id: row.id, slug: row.slug, type };
|
|
1350
|
+
}
|
|
1351
|
+
function createInputForArchiveItem(item, type, slug, options) {
|
|
1352
|
+
const base = {
|
|
1353
|
+
type,
|
|
1354
|
+
slug,
|
|
1355
|
+
title: item.title ?? 'Untitled',
|
|
1356
|
+
excerpt: item.excerpt ?? null,
|
|
1357
|
+
primaryCategory: item.primaryCategory ?? options.defaultPrimaryCategory ?? 'analysis',
|
|
1358
|
+
primaryTopic: item.primaryTopic ?? options.defaultPrimaryTopic ?? null,
|
|
1359
|
+
tags: item.tags ?? [],
|
|
1360
|
+
content: {
|
|
1361
|
+
bodyMarkdown: item.bodyMarkdown ?? ' ',
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
if (type === 'article')
|
|
1365
|
+
return { ...base, type: 'article' };
|
|
1366
|
+
if (type === 'newsletter')
|
|
1367
|
+
return { ...base, type: 'newsletter' };
|
|
1368
|
+
return { ...base, type: 'page' };
|
|
1369
|
+
}
|
|
1370
|
+
export async function importContentArchive(db, archive, options = {}) {
|
|
1371
|
+
const created = [];
|
|
1372
|
+
const skipped = [];
|
|
1373
|
+
const items = Array.isArray(archive.items) ? archive.items : [];
|
|
1374
|
+
const cursor = parseArchiveCursor(options.cursor);
|
|
1375
|
+
const batchSize = options.batchSize ?? DEFAULT_IMPORT_BATCH_ITEMS;
|
|
1376
|
+
const maxBatchItems = options.maxBatchItems ?? DEFAULT_MAX_IMPORT_BATCH_ITEMS;
|
|
1377
|
+
const maxArchiveItems = options.maxArchiveItems ?? DEFAULT_MAX_ARCHIVE_ITEMS;
|
|
1378
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_ARCHIVE_BODY_BYTES;
|
|
1379
|
+
assertLimit('archive_item_limit_exceeded', 'archive item count exceeds the configured import limit', maxArchiveItems, items.length);
|
|
1380
|
+
if (cursor > items.length) {
|
|
1381
|
+
throw new ContentArchiveImportLimitError('archive_cursor_out_of_range', 'archive import cursor exceeds the archive item count', items.length, cursor);
|
|
1382
|
+
}
|
|
1383
|
+
assertLimit('archive_batch_limit_exceeded', 'archive import batch size exceeds the configured import limit', maxBatchItems, batchSize);
|
|
1384
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
1385
|
+
throw new ContentArchiveImportLimitError('archive_batch_limit_invalid', 'archive import batch size must be a positive integer', 1, batchSize);
|
|
1386
|
+
}
|
|
1387
|
+
const end = Math.min(items.length, cursor + batchSize);
|
|
1388
|
+
for (let index = cursor; index < end; index += 1) {
|
|
1389
|
+
const item = items[index];
|
|
1390
|
+
if (!item)
|
|
1391
|
+
continue;
|
|
1392
|
+
const type = bodyBackedArchiveType(item.type);
|
|
1393
|
+
if (!type) {
|
|
1394
|
+
skipped.push({ index, reason: 'unsupported_type', title: item.title ?? null });
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (!item.title?.trim()) {
|
|
1398
|
+
skipped.push({ index, reason: 'missing_title', title: null });
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
const requestedSlug = item.slug?.trim() || item.title;
|
|
1402
|
+
const baseSlug = slugify(requestedSlug);
|
|
1403
|
+
const bodyMarkdown = item.bodyMarkdown ?? ' ';
|
|
1404
|
+
assertLimit('archive_body_limit_exceeded', 'archive item body exceeds the configured import limit', maxBodyBytes, textBytes(bodyMarkdown));
|
|
1405
|
+
const existing = await findContentBySlug(db, baseSlug);
|
|
1406
|
+
if (existing) {
|
|
1407
|
+
skipped.push({
|
|
1408
|
+
index,
|
|
1409
|
+
reason: 'already_imported',
|
|
1410
|
+
title: item.title,
|
|
1411
|
+
slug: existing.slug,
|
|
1412
|
+
existingId: existing.id,
|
|
1413
|
+
});
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
const slug = await ensureUniqueSlug(db, requestedSlug);
|
|
1417
|
+
const result = await createContent(db, createInputForArchiveItem(item, type, slug, options));
|
|
1418
|
+
created.push({ id: result.id, slug: result.slug, type });
|
|
1419
|
+
}
|
|
1420
|
+
const nextCursor = end >= items.length ? null : end;
|
|
1421
|
+
return {
|
|
1422
|
+
created,
|
|
1423
|
+
skipped,
|
|
1424
|
+
processed: end - cursor,
|
|
1425
|
+
cursor,
|
|
1426
|
+
nextCursor,
|
|
1427
|
+
done: nextCursor === null,
|
|
1428
|
+
checkpoint: {
|
|
1429
|
+
cursor,
|
|
1430
|
+
nextCursor,
|
|
1431
|
+
batchSize,
|
|
1432
|
+
totalItems: items.length,
|
|
1433
|
+
},
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
//# sourceMappingURL=publication.js.map
|