@bettercms-ai/content 0.1.0 → 0.3.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/dist/index.js +27 -27
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts +31 -2
- package/dist/preview.js +18 -27
- package/dist/preview.js.map +1 -1
- package/dist/publish.d.ts +12 -0
- package/dist/publish.js +11 -0
- package/dist/publish.js.map +1 -1
- package/package.json +1 -4
package/dist/index.js
CHANGED
|
@@ -8,17 +8,27 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
8
8
|
// src/preview.ts
|
|
9
9
|
import { SignJWT, jwtVerify } from "jose";
|
|
10
10
|
var DEFAULT_EXPIRY = "24h";
|
|
11
|
-
|
|
11
|
+
var MAX_PREVIEW_EXPIRY_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
12
|
+
var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
13
|
+
function expiryMs(expiresIn = DEFAULT_EXPIRY) {
|
|
14
|
+
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
15
|
+
if (!match) {
|
|
16
|
+
throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);
|
|
17
|
+
}
|
|
18
|
+
return parseInt(match[1], 10) * UNIT_MS[match[2]];
|
|
19
|
+
}
|
|
20
|
+
async function generatePreviewToken(entryId, entrySlug, workspaceId, previewTokenVersion, secret, expiresIn = DEFAULT_EXPIRY, type = "content-entry", jti, branch) {
|
|
12
21
|
const encoder = new TextEncoder();
|
|
13
22
|
const key = encoder.encode(secret);
|
|
14
|
-
const
|
|
23
|
+
const signer = new SignJWT({
|
|
15
24
|
entryId,
|
|
16
25
|
entrySlug,
|
|
17
26
|
workspaceId,
|
|
18
27
|
previewTokenVersion,
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
type,
|
|
29
|
+
...branch ? { branch } : {}
|
|
30
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn);
|
|
31
|
+
return (jti ? signer.setJti(jti) : signer).sign(key);
|
|
22
32
|
}
|
|
23
33
|
async function verifyPreviewToken(token, secret) {
|
|
24
34
|
const encoder = new TextEncoder();
|
|
@@ -29,28 +39,7 @@ async function verifyPreviewToken(token, secret) {
|
|
|
29
39
|
return payload;
|
|
30
40
|
}
|
|
31
41
|
function calculateExpiry(expiresIn = DEFAULT_EXPIRY) {
|
|
32
|
-
|
|
33
|
-
if (!match) {
|
|
34
|
-
throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);
|
|
35
|
-
}
|
|
36
|
-
const value = parseInt(match[1], 10);
|
|
37
|
-
const unit = match[2];
|
|
38
|
-
const now = /* @__PURE__ */ new Date();
|
|
39
|
-
switch (unit) {
|
|
40
|
-
case "s":
|
|
41
|
-
now.setSeconds(now.getSeconds() + value);
|
|
42
|
-
break;
|
|
43
|
-
case "m":
|
|
44
|
-
now.setMinutes(now.getMinutes() + value);
|
|
45
|
-
break;
|
|
46
|
-
case "h":
|
|
47
|
-
now.setHours(now.getHours() + value);
|
|
48
|
-
break;
|
|
49
|
-
case "d":
|
|
50
|
-
now.setDate(now.getDate() + value);
|
|
51
|
-
break;
|
|
52
|
-
}
|
|
53
|
-
return now.toISOString();
|
|
42
|
+
return new Date(Date.now() + expiryMs(expiresIn)).toISOString();
|
|
54
43
|
}
|
|
55
44
|
function buildPreviewUrl(baseUrl, slug, token) {
|
|
56
45
|
return `${baseUrl}/api/v1/preview/${slug}?token=${encodeURIComponent(token)}`;
|
|
@@ -243,6 +232,17 @@ async function rebuildAfterPublish(pool, hooks, table, entryId, workspaceId, rec
|
|
|
243
232
|
id: entryId
|
|
244
233
|
});
|
|
245
234
|
}
|
|
235
|
+
try {
|
|
236
|
+
await hooks.onChanged?.({
|
|
237
|
+
projectId: row.project_id,
|
|
238
|
+
workspaceId,
|
|
239
|
+
kind: table === "pages" ? "page" : "entry",
|
|
240
|
+
id: entryId,
|
|
241
|
+
reason: record ? "publish" : "unpublish"
|
|
242
|
+
});
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error(`[publish] change signals failed for ${table} ${entryId}:`, err.message);
|
|
245
|
+
}
|
|
246
246
|
await hooks.enqueueRebuild(row.project_id, workspaceId);
|
|
247
247
|
} catch (err) {
|
|
248
248
|
console.error(
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/preview.ts","../src/publish.ts"],"sourcesContent":["/**\n * JWT Preview Token Generation and Verification\n *\n * Uses jose library for JWT handling in Cloudflare Workers.\n * Tokens allow time-limited draft content access without authentication.\n *\n * FLO-110: Preview token system for sharing unpublished drafts\n */\n\nimport { SignJWT, jwtVerify, type JWTPayload } from 'jose';\n\nconst DEFAULT_EXPIRY = '24h'; // Default token expiry of 24 hours\n\nexport interface PreviewTokenPayload extends JWTPayload {\n entryId: string;\n entrySlug: string;\n workspaceId: string;\n previewTokenVersion: number;\n type: 'content-entry' | 'page';\n}\n\n/**\n * Generate a JWT preview token for a content entry or page.\n *\n * @param entryId - The content entry or page ID\n * @param entrySlug - The slug of the entry (used in preview URL)\n * @param workspaceId - The workspace ID (used for RLS enforcement)\n * @param previewTokenVersion - The current preview token version for invalidation\n * @param secret - The PREVIEW_SECRET env var (must be at least 32 chars)\n * @param expiresIn - Token expiry (default: 24h)\n * @param type - Whether this is for a content entry or page\n */\nexport async function generatePreviewToken(\n entryId: string,\n entrySlug: string,\n workspaceId: string,\n previewTokenVersion: number,\n secret: string,\n expiresIn: string = DEFAULT_EXPIRY,\n type: 'content-entry' | 'page' = 'content-entry'\n): Promise<string> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const token = await new SignJWT({\n entryId,\n entrySlug,\n workspaceId,\n previewTokenVersion,\n type,\n })\n .setProtectedHeader({ alg: 'HS256' })\n .setIssuedAt()\n .setExpirationTime(expiresIn)\n .sign(key);\n\n return token;\n}\n\n/**\n * Verify a JWT preview token.\n *\n * @param token - The JWT token from query parameter\n * @param secret - The PREVIEW_SECRET env var\n * @returns The decoded payload if valid, throws if invalid\n */\nexport async function verifyPreviewToken(\n token: string,\n secret: string\n): Promise<PreviewTokenPayload> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const { payload } = await jwtVerify(token, key, {\n algorithms: ['HS256'],\n });\n\n return payload as PreviewTokenPayload;\n}\n\n/**\n * Calculate expiry timestamp for a token.\n *\n * @param expiresIn - Duration string (e.g., '24h', '7d')\n * @returns ISO string of when the token expires\n */\nexport function calculateExpiry(expiresIn: string = DEFAULT_EXPIRY): string {\n const match = expiresIn.match(/^(\\d+)([smhd])$/);\n if (!match) {\n throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);\n }\n\n const value = parseInt(match[1], 10);\n const unit = match[2];\n\n const now = new Date();\n switch (unit) {\n case 's':\n now.setSeconds(now.getSeconds() + value);\n break;\n case 'm':\n now.setMinutes(now.getMinutes() + value);\n break;\n case 'h':\n now.setHours(now.getHours() + value);\n break;\n case 'd':\n now.setDate(now.getDate() + value);\n break;\n }\n\n return now.toISOString();\n}\n\n/**\n * Build a full preview URL with token embedded.\n *\n * @param baseUrl - The API base URL (e.g., https://api.bettercms.ai)\n * @param slug - The entry slug\n * @param token - The JWT preview token\n */\nexport function buildPreviewUrl(baseUrl: string, slug: string, token: string): string {\n return `${baseUrl}/api/v1/preview/${slug}?token=${encodeURIComponent(token)}`;\n}\n\n/**\n * Decide the entry/page previewTokenVersion after a write.\n *\n * Preview tokens embed previewTokenVersion; bumping it invalidates every live\n * preview link. Bump when the publish boundary is crossed in EITHER direction —\n * publish (→ published) or unpublish (published → draft) — but NOT on\n * content-only edits, so auto-save keeps shared preview links alive.\n *\n * @param existingStatus - The entry's current status before this write\n * @param newStatus - The status in the incoming body, or undefined if unchanged\n * @param currentVersion - The entry's current previewTokenVersion\n */\nexport function nextPreviewTokenVersion(\n existingStatus: string,\n newStatus: string | undefined,\n currentVersion: number,\n): number {\n const publishing = newStatus === 'published';\n const unpublishing = newStatus === 'draft' && existingStatus === 'published';\n return publishing || unpublishing ? currentVersion + 1 : currentVersion;\n}","/**\n * Scheduled Publishing Logic\n *\n * Handles publishing of scheduled content entries and pages.\n * Includes retry logic with max 3 attempts before alerting Sentry.\n *\n * FLO-116: Scheduled publishing with automatic retry\n */\n\n/**\n * Minimal structural handle this module uses — a postgres.js pool or a dedicated\n * connection. `unsafe` resolves to the row array; `begin` runs a transaction on one\n * connection. A real postgres.js client satisfies this at runtime (callers pass it\n * through a cast), keeping these query helpers driver-version agnostic.\n */\ninterface Pool {\n unsafe<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;\n begin<T>(fn: (tx: Pool) => Promise<T>): Promise<T>;\n end(): Promise<void>;\n}\nimport { upsertPage, removePage, extractBlockText, upsertEntry, extractEntryText, removeEntry } from '@bettercms-ai/db';\nimport { isEntryNoindex } from '@bettercms-ai/db/entry-noindex';\n\n// Sentry is imported conditionally to avoid issues when not configured\nlet Sentry: typeof import('@sentry/node') | null = null;\n\ntry {\n Sentry = require('@sentry/node');\n} catch {\n // Sentry not configured, continue without it\n}\n\nconst MAX_RETRY_COUNT = 3;\n\n/**\n * Follow-on work a publish triggers that this package deliberately does not own.\n *\n * The scheduled-publish cron runs inside the backend (src/lib/publish/scheduled.ts), which\n * holds the one funnel every publish in the codebase passes through: the platform publishing\n * lock, the project's external deploy targets, the publish backup, the debounced rebuild queue\n * and the drain kick. Injecting it keeps that funnel single-copy instead of re-implemented for\n * the cron runtime, which is how scheduled publishes came to skip custom hosts entirely.\n */\nexport interface PublishHooks {\n /** The backend's `enqueueProjectRebuild(projectId, workspaceId)`. */\n enqueueRebuild(projectId: string, workspaceId: string): Promise<unknown>;\n /**\n * Record the publish in the project's history, so a scheduled publish appears in the timeline\n * beside the ones a human pressed. Optional: the undeployed cron worker cannot reach it.\n */\n recordPublish?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n }): Promise<unknown>;\n}\n\ninterface PublishResult {\n success: boolean;\n entryId: string;\n error?: string;\n retryable: boolean;\n}\n\n/**\n * The publish lock's verdict on one due content entry, taken when the schedule FIRES (CPO-039).\n * The backend supplies it: this package has no model schemas. `detail` rides through to\n * `blocked` untouched, so the caller can tell the author why.\n */\nexport type ScheduledPreflightResult =\n | { ok: true }\n | { ok: false; reason: string; detail?: Record<string, unknown> };\n\nexport type ScheduledPreflight = (entry: { id: string; workspaceId: string }) => Promise<ScheduledPreflightResult>;\n\n/** A due entry the publish lock refused. It stays a draft and its schedule is spent. */\nexport type BlockedSchedule = {\n id: string;\n workspaceId: string;\n reason: string;\n detail?: Record<string, unknown>;\n};\n\n/**\n * Publish all scheduled entries that are due for publication.\n * This is the main function called by the cron worker.\n *\n * @param pool - PostgreSQL connection pool\n * @param opts.preflightEntry - re-runs the publish lock on each due CONTENT ENTRY right before\n * it goes live. The content can change after the schedule was validated (another entry takes\n * the SKU, a required field is emptied through a lane that doesn't validate), and a schedule\n * is a publish on a timer, so it gets the same gate as the button. Absent, nothing is checked.\n * @returns Summary of published entries; `blocked` are the ones the preflight refused (also in `failed`)\n */\nexport async function publishScheduledEntries(\n pool: Pool,\n hooks: PublishHooks,\n opts: { preflightEntry?: ScheduledPreflight } = {}\n): Promise<{\n published: string[];\n failed: string[];\n skipped: string[];\n blocked: BlockedSchedule[];\n}> {\n const now = new Date();\n const result = {\n published: [] as string[],\n failed: [] as string[],\n skipped: [] as string[],\n blocked: [] as BlockedSchedule[],\n };\n\n // Fetch due content entries (scheduled_at <= now, status = draft) ACROSS all workspaces.\n // Via SECURITY DEFINER list_due_scheduled_entries (mig 0056): the scan has no single workspace\n // context, so under the restricted role a direct RLS-gated scan returns 0. Per-workspace\n // context is then set before each publish UPDATE below.\n const entries = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_entries($1)`,\n [now.toISOString()]\n );\n\n // Fetch due pages (same definer-scan rationale).\n const pages = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_pages($1)`,\n [now.toISOString()]\n );\n\n // Process content entries\n for (const entry of entries) {\n if (opts.preflightEntry) {\n let check: ScheduledPreflightResult;\n try {\n check = await opts.preflightEntry({ id: entry.id, workspaceId: entry.workspace_id });\n } catch (error) {\n // The CHECK couldn't run (a DB hiccup), which says nothing about the entry: the\n // ordinary retry path, same as a failed publish UPDATE.\n const failure = await recordPublishFailure(pool, 'content_entries', entry.id, entry.workspace_id, error as Error);\n (failure.retryable ? result.skipped : result.failed).push(entry.id);\n continue;\n }\n if (!check.ok) {\n await blockSchedule(pool, entry.id, entry.workspace_id, check.reason);\n result.blocked.push({\n id: entry.id,\n workspaceId: entry.workspace_id,\n reason: check.reason,\n ...(check.detail ? { detail: check.detail } : {}),\n });\n result.failed.push(entry.id);\n continue;\n }\n }\n\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'content_entries',\n entry.id,\n entry.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(entry.id);\n } else if (pubResult.retryable) {\n result.skipped.push(entry.id);\n } else {\n result.failed.push(entry.id);\n }\n }\n\n // Process pages\n for (const page of pages) {\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'pages',\n page.id,\n page.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(page.id);\n } else if (pubResult.retryable) {\n result.skipped.push(page.id);\n } else {\n result.failed.push(page.id);\n }\n }\n\n // Log summary\n console.log(\n `Scheduled publishing: ${result.published.length} published, ` +\n `${result.skipped.length} skipped (retry), ${result.failed.length} failed ` +\n `(${result.blocked.length} blocked by the publish lock)`\n );\n\n return result;\n}\n\n/**\n * Return every PUBLISHED entry and page whose `unpublish_at` has passed to draft.\n *\n * The mirror of publishScheduledEntries, and deliberately a separate pass rather than a mode\n * flag on it: the two scan disjoint row sets (that one wants drafts, this one wants published\n * rows), and folding them together would mean one loop whose every step asks which direction\n * it is going.\n *\n * No retry counter. `scheduled_retry_count` exists for publishing because publishing does\n * fallible follow-on work — a rebuild enqueue, a search index write. Unpublishing does the\n * same follow-on work, but if it fails the row is ALREADY back to draft, which is the outcome\n * the schedule asked for; retrying a status change that already happened would be a no-op with\n * a counter attached. A failure here is logged and the next tick simply finds nothing due.\n */\nexport async function unpublishDueEntries(pool: Pool, hooks: PublishHooks): Promise<{\n unpublished: string[];\n failed: string[];\n}> {\n const now = new Date();\n const result = { unpublished: [] as string[], failed: [] as string[] };\n\n // Definer-scan for the same reason the publish path uses one: a cron tick has no single\n // workspace context, so an RLS-gated scan under the restricted role returns zero rows and\n // the schedule would silently never fire.\n const rows = await Promise.all(\n (['content_entries', 'pages'] as const).map(async (table) => {\n const fn = table === 'content_entries' ? 'list_due_unpublish_entries' : 'list_due_unpublish_pages';\n const due = await pool.unsafe<{ id: string; workspace_id: string }>(\n `SELECT id, workspace_id FROM ${fn}($1)`,\n [now.toISOString()]\n );\n return due.map((d) => ({ ...d, table }));\n })\n );\n\n for (const row of rows.flat()) {\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [row.workspace_id]);\n\n // Guarded on status = 'published' so a race with a manual unpublish is a no-op rather\n // than a second version bump. unpublish_at is cleared either way: the schedule fired.\n const updated = await pool.unsafe<{ id: string }>(\n `UPDATE ${row.table}\n SET status = 'draft',\n unpublish_at = NULL,\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'published'\n RETURNING id`,\n [row.id]\n );\n\n if (updated.length === 0) {\n // Already unpublished by hand between the scan and now — clear the stale schedule so\n // the next tick does not keep finding it.\n await pool.unsafe(`UPDATE ${row.table} SET unpublish_at = NULL WHERE id = $1`, [row.id]);\n continue;\n }\n\n // The live site still serves the old copy until it rebuilds — the same reason the\n // publish path enqueues one.\n await rebuildAfterPublish(pool, hooks, row.table, row.id, row.workspace_id, false);\n // …and site search still returns it until the document is dropped. Leaving it indexed\n // is the visible half of the bug: the page 404s but the search result still points at it.\n await dropFromSearchIndex(row.id);\n result.unpublished.push(row.id);\n } catch (error) {\n const err = error as Error;\n console.error(`Failed to unpublish ${row.table} ${row.id}:`, err.message);\n alertSentryFailure(row.table, row.id, err.message, 0);\n result.failed.push(row.id);\n }\n }\n\n console.log(\n `Scheduled unpublishing: ${result.unpublished.length} unpublished, ${result.failed.length} failed`\n );\n return result;\n}\n\n/**\n * Drop a document from site search. Entries and pages share one Typesense collection keyed by\n * id, so one call covers both. Best-effort and non-fatal for the same reason the index-on-\n * publish calls are: the status change is the contract, and search is downstream of it — a\n * Typesense outage must not leave a row published when the schedule said to retire it.\n */\nasync function dropFromSearchIndex(id: string): Promise<void> {\n try {\n await removeEntry(id);\n } catch (err) {\n console.error(`[search] drop on scheduled unpublish failed for ${id}:`, (err as Error).message);\n }\n}\n\n/**\n * Publish a single entry (content entry or page).\n */\nasync function publishEntry(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string\n): Promise<PublishResult> {\n try {\n // Set workspace context for RLS\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n\n // Attempt to publish\n const result = await pool.unsafe<{ optimistic_version: number }>(\n `UPDATE ${table}\n SET status = 'published',\n scheduled_at = NULL,\n published_at = now(),\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'draft'\n RETURNING optimistic_version`,\n [entryId]\n );\n\n if (result.length === 0) {\n // Entry was already published or doesn't exist\n return { success: true, entryId, retryable: false };\n }\n\n // A scheduled draft just went live — redeploy the static site so the change\n // reaches <subdomain>.bettercms.site, mirroring the interactive publish path.\n await rebuildAfterPublish(pool, hooks, table, entryId, workspaceId);\n\n // FLO-60/FLO-199: index the freshly published item into Typesense — pages by\n // block content, content entries by their field data.\n if (table === 'pages') {\n await indexPageOnPublish(pool, entryId, workspaceId);\n } else {\n await indexEntryOnPublish(pool, entryId, workspaceId);\n }\n\n return { success: true, entryId, retryable: false };\n } catch (error) {\n return recordPublishFailure(pool, table, entryId, workspaceId, error as Error);\n }\n}\n\n/**\n * The publish lock refused a due entry: an empty visible required field, a unique value another\n * entry holds, or a section zone that isn't ready. Retrying can't fix content, so the schedule\n * ends the way exhausted retries end: `scheduled_at` cleared, `scheduled_retry_count` at its\n * maximum, the entry left a draft. There is no error column to write to (no migration); the\n * caller notifies the author with the reason.\n */\nasync function blockSchedule(pool: Pool, entryId: string, workspaceId: string, reason: string): Promise<void> {\n console.warn(`Scheduled publish blocked for content_entries ${entryId}: ${reason}`);\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n await pool.unsafe(\n `UPDATE content_entries\n SET scheduled_at = NULL,\n scheduled_retry_count = $2\n WHERE id = $1\n AND status = 'draft'`,\n [entryId, MAX_RETRY_COUNT]\n );\n } catch (error) {\n // The next tick finds it due again and re-checks; nothing is published either way.\n console.error(`Failed to end blocked schedule for content_entries ${entryId}:`, (error as Error).message);\n }\n}\n\n/**\n * A publish attempt (or its preflight) failed for a reason that may pass on retry: count it,\n * and after MAX_RETRY_COUNT clear the schedule and alert Sentry.\n */\nasync function recordPublishFailure(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n err: Error\n): Promise<PublishResult> {\n {\n console.error(`Failed to publish ${table} ${entryId}:`, err.message);\n\n // Increment retry count\n try {\n // The preflight path reaches here before publishEntry set this entry's workspace.\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n const retryResult = await pool.unsafe<{ scheduled_retry_count: number }>(\n `UPDATE ${table}\n SET scheduled_retry_count = scheduled_retry_count + 1\n WHERE id = $1\n RETURNING scheduled_retry_count`,\n [entryId]\n );\n\n const retryCount = retryResult[0]?.scheduled_retry_count ?? 0;\n\n if (retryCount >= MAX_RETRY_COUNT) {\n // Max retries reached - clear scheduled_at and alert Sentry\n await pool.unsafe(\n `UPDATE ${table} SET scheduled_at = NULL WHERE id = $1`,\n [entryId]\n );\n\n alertSentryFailure(table, entryId, err.message, retryCount);\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n } catch {\n // Failed to update retry count, don't retry\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n\n // Retryable failure\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: true,\n };\n }\n}\n\n/**\n * Ask the backend to rebuild the project this row belongs to.\n *\n * 🔴 THIS USED TO UPSERT `rebuild_queue` ITSELF, and that is the whole bug. A second copy of\n * the publish funnel only queues our own static build: it never fires the project's deploy\n * targets, the revalidation webhook, or the drain kick — so a page scheduled for 09:00 on a\n * Vercel-hosted project published, and never reached Vercel. The funnel lives in the backend\n * (src/lib/hosting/rebuild-queue.ts `enqueueProjectRebuild`) and is injected, because this\n * package is driver-agnostic (a raw postgres.js handle) while the funnel speaks drizzle.\n *\n * All this still owns is the lookup the caller cannot do: which project the row belongs to.\n * Reads on the handle the caller already set the workspace context on.\n *\n * Best-effort: a failure here must never fail the publish it follows.\n */\nasync function rebuildAfterPublish(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n /** False on the unpublish lane — it rebuilds, but it is not a publish to record. */\n record = true\n): Promise<void> {\n try {\n const [row] = await pool.unsafe<{ project_id: string | null }>(\n `SELECT project_id FROM ${table} WHERE id = $1`,\n [entryId]\n );\n if (!row?.project_id) return; // not part of a project — nothing hosts it\n // Only a PUBLISH goes in the publish history. A scheduled retirement still needs the\n // rebuild below, but recording it would put a row in the timeline saying the page was\n // published at the moment it came down.\n if (record) {\n await hooks.recordPublish?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n });\n }\n await hooks.enqueueRebuild(row.project_id, workspaceId);\n } catch (err) {\n console.error(\n `[rebuild] enqueue on scheduled publish failed for ${table} ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-60: upsert a freshly published page into the Typesense `pages` collection,\n * mirroring the interactive publish path (src/routes/admin/pages.ts). Reads the\n * page in a workspace-scoped transaction (RLS), then upserts via the shared\n * @bettercms-ai/db helper (the typesense client is env-driven, not pool-bound).\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexPageOnPublish(\n pool: Pool,\n pageId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n title: string | null;\n slug: string | null;\n block_json: unknown;\n noindex: boolean | null;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, title, slug, block_json, noindex, updated_at FROM pages WHERE id = $1`,\n [pageId]\n );\n });\n\n const page = rows[0];\n if (!page) return;\n\n // A page scheduled while hidden from search goes live hidden: keep it out of site search,\n // as the interactive publish path and list_search_docs (pg.noindex = false) already do.\n if (page.noindex === true) {\n await removePage(String(page.id)).catch(() => {});\n return;\n }\n\n await upsertPage({\n id: String(page.id),\n workspace_id: String(page.workspace_id),\n project_id: page.project_id ?? '',\n type: 'page',\n title: String(page.title ?? ''),\n slug: String(page.slug ?? ''),\n content: extractBlockText((page.block_json as Array<Record<string, unknown>>) ?? []),\n updated_at: page.updated_at ? new Date(page.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for page ${pageId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-199: upsert a freshly published content entry into the Typesense collection,\n * mirroring indexPageOnPublish. Reads the entry in a workspace-scoped transaction\n * (RLS), then upserts via the shared @bettercms-ai/db helper. Title falls back to the\n * slug when the entry has no `data.title`.\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexEntryOnPublish(\n pool: Pool,\n entryId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n slug: string | null;\n data: unknown;\n meta_json: unknown;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, slug, data, meta_json, updated_at FROM content_entries WHERE id = $1`,\n [entryId]\n );\n });\n\n const entry = rows[0];\n if (!entry) return;\n\n // #735: an entry hidden from search stays out of site search when its schedule fires —\n // the same predicate indexEntry and reconcileProject use, so this lane cannot drift from them.\n // Removing rather than just skipping also clears a document an earlier publish left behind.\n if (isEntryNoindex(entry.meta_json)) {\n await removeEntry(String(entry.id)).catch(() => {});\n return;\n }\n\n const data = (entry.data as Record<string, unknown>) ?? {};\n const title = typeof data.title === 'string' && data.title.trim() ? data.title : String(entry.slug ?? '');\n\n await upsertEntry({\n id: String(entry.id),\n workspace_id: String(entry.workspace_id),\n project_id: entry.project_id ?? '',\n type: 'entry',\n title,\n slug: String(entry.slug ?? ''),\n content: extractEntryText(data),\n updated_at: entry.updated_at ? new Date(entry.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for entry ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * Alert Sentry about a persistent publishing failure.\n */\nfunction alertSentryFailure(\n table: string,\n entryId: string,\n error: string,\n retryCount: number\n): void {\n if (Sentry) {\n Sentry.captureMessage(\n `Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId}`,\n {\n level: 'error',\n extra: {\n table,\n entryId,\n error,\n retryCount,\n },\n }\n );\n } else {\n console.error(\n `[ALERT] Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId} - ${error}`\n );\n }\n}\n\n/**\n * Clear all scheduled publishes for an entry (for cancellation).\n *\n * @param pool - PostgreSQL connection pool\n * @param table - Table name (content_entries or pages)\n * @param entryId - Entry ID to clear\n */\nexport async function clearScheduledPublish(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string\n): Promise<void> {\n await pool.unsafe(\n `UPDATE ${table}\n SET scheduled_at = NULL,\n scheduled_retry_count = 0\n WHERE id = $1`,\n [entryId]\n );\n}\n\n/**\n * Get next scheduled publish time (for health checks).\n */\nexport async function getNextScheduledPublish(pool: Pool): Promise<Date | null> {\n const result = await pool.unsafe<{ next_at: Date }>(\n `SELECT MIN(scheduled_at) as next_at\n FROM (\n SELECT scheduled_at FROM content_entries WHERE scheduled_at IS NOT NULL\n UNION ALL\n SELECT scheduled_at FROM pages WHERE scheduled_at IS NOT NULL\n ) t`\n );\n\n return result[0]?.next_at ?? null;\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/preview.ts","../src/publish.ts"],"sourcesContent":["/**\n * JWT Preview Token Generation and Verification\n *\n * Uses jose library for JWT handling in Cloudflare Workers.\n * Tokens allow time-limited draft content access without authentication.\n *\n * FLO-110: Preview token system for sharing unpublished drafts\n */\n\nimport { SignJWT, jwtVerify, type JWTPayload } from 'jose';\n\nconst DEFAULT_EXPIRY = '24h'; // Default token expiry of 24 hours\n\n/**\n * The server's cap on any preview or share token (owner decision Q3, PRD invariant 15).\n *\n * `expiresIn` came straight from the request body with nothing above it, so `9999d` minted a\n * token that outlived the content, the campaign and usually the employee — and there was no way\n * to withdraw a single one, because the only lever was bumping the entry's previewTokenVersion,\n * which kills every link to that entry at once.\n */\nexport const MAX_PREVIEW_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000;\n\nconst UNIT_MS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };\n\n/**\n * A `24h` / `7d` / `60m` duration in milliseconds.\n *\n * Extracted so the CAP and the EXPIRY are read off one parse. Two parsers would be two places to\n * accept `9999d`, and the one that drifted would be the one the clamp used.\n */\nexport function expiryMs(expiresIn: string = DEFAULT_EXPIRY): number {\n const match = expiresIn.match(/^(\\d+)([smhd])$/);\n if (!match) {\n throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);\n }\n return parseInt(match[1]!, 10) * UNIT_MS[match[2]!]!;\n}\n\nexport interface PreviewTokenPayload extends JWTPayload {\n entryId: string;\n entrySlug: string;\n workspaceId: string;\n previewTokenVersion: number;\n type: 'content-entry' | 'page';\n /** Present on every token minted after 0355; absent on older, still-valid links. */\n jti?: string;\n}\n\n/**\n * Generate a JWT preview token for a content entry or page.\n *\n * @param entryId - The content entry or page ID\n * @param entrySlug - The slug of the entry (used in preview URL)\n * @param workspaceId - The workspace ID (used for RLS enforcement)\n * @param previewTokenVersion - The current preview token version for invalidation\n * @param secret - The PREVIEW_SECRET env var (must be at least 32 chars)\n * @param expiresIn - Token expiry (default: 24h)\n * @param type - Whether this is for a content entry or page\n */\nexport async function generatePreviewToken(\n entryId: string,\n entrySlug: string,\n workspaceId: string,\n previewTokenVersion: number,\n secret: string,\n expiresIn: string = DEFAULT_EXPIRY,\n type: 'content-entry' | 'page' = 'content-entry',\n /**\n * A per-token id, so ONE link can be withdrawn. Without it the only revocation lever is the\n * entry's previewTokenVersion, which kills every link to that entry — the reason a leaked\n * share link used to mean telling everyone else their link had stopped working too.\n */\n jti?: string,\n /**\n * The content branch (its slug) the entry or page lives on, when not main. A headless front end's\n * draft route reads it (signed, so it can be trusted) to preview the whole site from that branch.\n */\n branch?: string,\n): Promise<string> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const signer = new SignJWT({\n entryId,\n entrySlug,\n workspaceId,\n previewTokenVersion,\n type,\n ...(branch ? { branch } : {}),\n })\n .setProtectedHeader({ alg: 'HS256' })\n .setIssuedAt()\n .setExpirationTime(expiresIn);\n\n return (jti ? signer.setJti(jti) : signer).sign(key);\n}\n\n/**\n * Verify a JWT preview token.\n *\n * @param token - The JWT token from query parameter\n * @param secret - The PREVIEW_SECRET env var\n * @returns The decoded payload if valid, throws if invalid\n */\nexport async function verifyPreviewToken(\n token: string,\n secret: string\n): Promise<PreviewTokenPayload> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const { payload } = await jwtVerify(token, key, {\n algorithms: ['HS256'],\n });\n\n return payload as PreviewTokenPayload;\n}\n\n/**\n * Calculate expiry timestamp for a token.\n *\n * @param expiresIn - Duration string (e.g., '24h', '7d')\n * @returns ISO string of when the token expires\n */\nexport function calculateExpiry(expiresIn: string = DEFAULT_EXPIRY): string {\n return new Date(Date.now() + expiryMs(expiresIn)).toISOString();\n}\n\n/**\n * Build a full preview URL with token embedded.\n *\n * @param baseUrl - The API base URL (e.g., https://api.bettercms.ai)\n * @param slug - The entry slug\n * @param token - The JWT preview token\n */\nexport function buildPreviewUrl(baseUrl: string, slug: string, token: string): string {\n return `${baseUrl}/api/v1/preview/${slug}?token=${encodeURIComponent(token)}`;\n}\n\n/**\n * Decide the entry/page previewTokenVersion after a write.\n *\n * Preview tokens embed previewTokenVersion; bumping it invalidates every live\n * preview link. Bump when the publish boundary is crossed in EITHER direction —\n * publish (→ published) or unpublish (published → draft) — but NOT on\n * content-only edits, so auto-save keeps shared preview links alive.\n *\n * @param existingStatus - The entry's current status before this write\n * @param newStatus - The status in the incoming body, or undefined if unchanged\n * @param currentVersion - The entry's current previewTokenVersion\n */\nexport function nextPreviewTokenVersion(\n existingStatus: string,\n newStatus: string | undefined,\n currentVersion: number,\n): number {\n const publishing = newStatus === 'published';\n const unpublishing = newStatus === 'draft' && existingStatus === 'published';\n return publishing || unpublishing ? currentVersion + 1 : currentVersion;\n}","/**\n * Scheduled Publishing Logic\n *\n * Handles publishing of scheduled content entries and pages.\n * Includes retry logic with max 3 attempts before alerting Sentry.\n *\n * FLO-116: Scheduled publishing with automatic retry\n */\n\n/**\n * Minimal structural handle this module uses — a postgres.js pool or a dedicated\n * connection. `unsafe` resolves to the row array; `begin` runs a transaction on one\n * connection. A real postgres.js client satisfies this at runtime (callers pass it\n * through a cast), keeping these query helpers driver-version agnostic.\n */\ninterface Pool {\n unsafe<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;\n begin<T>(fn: (tx: Pool) => Promise<T>): Promise<T>;\n end(): Promise<void>;\n}\nimport { upsertPage, removePage, extractBlockText, upsertEntry, extractEntryText, removeEntry } from '@bettercms-ai/db';\nimport { isEntryNoindex } from '@bettercms-ai/db/entry-noindex';\n\n// Sentry is imported conditionally to avoid issues when not configured\nlet Sentry: typeof import('@sentry/node') | null = null;\n\ntry {\n Sentry = require('@sentry/node');\n} catch {\n // Sentry not configured, continue without it\n}\n\nconst MAX_RETRY_COUNT = 3;\n\n/**\n * Follow-on work a publish triggers that this package deliberately does not own.\n *\n * The scheduled-publish cron runs inside the backend (src/lib/publish/scheduled.ts), which\n * holds the one funnel every publish in the codebase passes through: the platform publishing\n * lock, the project's external deploy targets, the publish backup, the debounced rebuild queue\n * and the drain kick. Injecting it keeps that funnel single-copy instead of re-implemented for\n * the cron runtime, which is how scheduled publishes came to skip custom hosts entirely.\n */\nexport interface PublishHooks {\n /** The backend's `enqueueProjectRebuild(projectId, workspaceId)`. */\n enqueueRebuild(projectId: string, workspaceId: string): Promise<unknown>;\n /**\n * Record the publish in the project's history, so a scheduled publish appears in the timeline\n * beside the ones a human pressed. Optional: the undeployed cron worker cannot reach it.\n */\n recordPublish?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n }): Promise<unknown>;\n /**\n * Tell the world the row went live or came down: lifecycle webhooks, the revalidation webhook,\n * the delivery-cache purge. Without it a scheduled publish reached our own build and nothing\n * else, and on a headless project (which never builds) it reached nobody at all.\n */\n onChanged?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n reason: 'publish' | 'unpublish';\n }): Promise<unknown>;\n}\n\ninterface PublishResult {\n success: boolean;\n entryId: string;\n error?: string;\n retryable: boolean;\n}\n\n/**\n * The publish lock's verdict on one due content entry, taken when the schedule FIRES (CPO-039).\n * The backend supplies it: this package has no model schemas. `detail` rides through to\n * `blocked` untouched, so the caller can tell the author why.\n */\nexport type ScheduledPreflightResult =\n | { ok: true }\n | { ok: false; reason: string; detail?: Record<string, unknown> };\n\nexport type ScheduledPreflight = (entry: { id: string; workspaceId: string }) => Promise<ScheduledPreflightResult>;\n\n/** A due entry the publish lock refused. It stays a draft and its schedule is spent. */\nexport type BlockedSchedule = {\n id: string;\n workspaceId: string;\n reason: string;\n detail?: Record<string, unknown>;\n};\n\n/**\n * Publish all scheduled entries that are due for publication.\n * This is the main function called by the cron worker.\n *\n * @param pool - PostgreSQL connection pool\n * @param opts.preflightEntry - re-runs the publish lock on each due CONTENT ENTRY right before\n * it goes live. The content can change after the schedule was validated (another entry takes\n * the SKU, a required field is emptied through a lane that doesn't validate), and a schedule\n * is a publish on a timer, so it gets the same gate as the button. Absent, nothing is checked.\n * @returns Summary of published entries; `blocked` are the ones the preflight refused (also in `failed`)\n */\nexport async function publishScheduledEntries(\n pool: Pool,\n hooks: PublishHooks,\n opts: { preflightEntry?: ScheduledPreflight } = {}\n): Promise<{\n published: string[];\n failed: string[];\n skipped: string[];\n blocked: BlockedSchedule[];\n}> {\n const now = new Date();\n const result = {\n published: [] as string[],\n failed: [] as string[],\n skipped: [] as string[],\n blocked: [] as BlockedSchedule[],\n };\n\n // Fetch due content entries (scheduled_at <= now, status = draft) ACROSS all workspaces.\n // Via SECURITY DEFINER list_due_scheduled_entries (mig 0056): the scan has no single workspace\n // context, so under the restricted role a direct RLS-gated scan returns 0. Per-workspace\n // context is then set before each publish UPDATE below.\n const entries = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_entries($1)`,\n [now.toISOString()]\n );\n\n // Fetch due pages (same definer-scan rationale).\n const pages = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_pages($1)`,\n [now.toISOString()]\n );\n\n // Process content entries\n for (const entry of entries) {\n if (opts.preflightEntry) {\n let check: ScheduledPreflightResult;\n try {\n check = await opts.preflightEntry({ id: entry.id, workspaceId: entry.workspace_id });\n } catch (error) {\n // The CHECK couldn't run (a DB hiccup), which says nothing about the entry: the\n // ordinary retry path, same as a failed publish UPDATE.\n const failure = await recordPublishFailure(pool, 'content_entries', entry.id, entry.workspace_id, error as Error);\n (failure.retryable ? result.skipped : result.failed).push(entry.id);\n continue;\n }\n if (!check.ok) {\n await blockSchedule(pool, entry.id, entry.workspace_id, check.reason);\n result.blocked.push({\n id: entry.id,\n workspaceId: entry.workspace_id,\n reason: check.reason,\n ...(check.detail ? { detail: check.detail } : {}),\n });\n result.failed.push(entry.id);\n continue;\n }\n }\n\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'content_entries',\n entry.id,\n entry.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(entry.id);\n } else if (pubResult.retryable) {\n result.skipped.push(entry.id);\n } else {\n result.failed.push(entry.id);\n }\n }\n\n // Process pages\n for (const page of pages) {\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'pages',\n page.id,\n page.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(page.id);\n } else if (pubResult.retryable) {\n result.skipped.push(page.id);\n } else {\n result.failed.push(page.id);\n }\n }\n\n // Log summary\n console.log(\n `Scheduled publishing: ${result.published.length} published, ` +\n `${result.skipped.length} skipped (retry), ${result.failed.length} failed ` +\n `(${result.blocked.length} blocked by the publish lock)`\n );\n\n return result;\n}\n\n/**\n * Return every PUBLISHED entry and page whose `unpublish_at` has passed to draft.\n *\n * The mirror of publishScheduledEntries, and deliberately a separate pass rather than a mode\n * flag on it: the two scan disjoint row sets (that one wants drafts, this one wants published\n * rows), and folding them together would mean one loop whose every step asks which direction\n * it is going.\n *\n * No retry counter. `scheduled_retry_count` exists for publishing because publishing does\n * fallible follow-on work — a rebuild enqueue, a search index write. Unpublishing does the\n * same follow-on work, but if it fails the row is ALREADY back to draft, which is the outcome\n * the schedule asked for; retrying a status change that already happened would be a no-op with\n * a counter attached. A failure here is logged and the next tick simply finds nothing due.\n */\nexport async function unpublishDueEntries(pool: Pool, hooks: PublishHooks): Promise<{\n unpublished: string[];\n failed: string[];\n}> {\n const now = new Date();\n const result = { unpublished: [] as string[], failed: [] as string[] };\n\n // Definer-scan for the same reason the publish path uses one: a cron tick has no single\n // workspace context, so an RLS-gated scan under the restricted role returns zero rows and\n // the schedule would silently never fire.\n const rows = await Promise.all(\n (['content_entries', 'pages'] as const).map(async (table) => {\n const fn = table === 'content_entries' ? 'list_due_unpublish_entries' : 'list_due_unpublish_pages';\n const due = await pool.unsafe<{ id: string; workspace_id: string }>(\n `SELECT id, workspace_id FROM ${fn}($1)`,\n [now.toISOString()]\n );\n return due.map((d) => ({ ...d, table }));\n })\n );\n\n for (const row of rows.flat()) {\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [row.workspace_id]);\n\n // Guarded on status = 'published' so a race with a manual unpublish is a no-op rather\n // than a second version bump. unpublish_at is cleared either way: the schedule fired.\n const updated = await pool.unsafe<{ id: string }>(\n `UPDATE ${row.table}\n SET status = 'draft',\n unpublish_at = NULL,\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'published'\n RETURNING id`,\n [row.id]\n );\n\n if (updated.length === 0) {\n // Already unpublished by hand between the scan and now — clear the stale schedule so\n // the next tick does not keep finding it.\n await pool.unsafe(`UPDATE ${row.table} SET unpublish_at = NULL WHERE id = $1`, [row.id]);\n continue;\n }\n\n // The live site still serves the old copy until it rebuilds — the same reason the\n // publish path enqueues one.\n await rebuildAfterPublish(pool, hooks, row.table, row.id, row.workspace_id, false);\n // …and site search still returns it until the document is dropped. Leaving it indexed\n // is the visible half of the bug: the page 404s but the search result still points at it.\n await dropFromSearchIndex(row.id);\n result.unpublished.push(row.id);\n } catch (error) {\n const err = error as Error;\n console.error(`Failed to unpublish ${row.table} ${row.id}:`, err.message);\n alertSentryFailure(row.table, row.id, err.message, 0);\n result.failed.push(row.id);\n }\n }\n\n console.log(\n `Scheduled unpublishing: ${result.unpublished.length} unpublished, ${result.failed.length} failed`\n );\n return result;\n}\n\n/**\n * Drop a document from site search. Entries and pages share one Typesense collection keyed by\n * id, so one call covers both. Best-effort and non-fatal for the same reason the index-on-\n * publish calls are: the status change is the contract, and search is downstream of it — a\n * Typesense outage must not leave a row published when the schedule said to retire it.\n */\nasync function dropFromSearchIndex(id: string): Promise<void> {\n try {\n await removeEntry(id);\n } catch (err) {\n console.error(`[search] drop on scheduled unpublish failed for ${id}:`, (err as Error).message);\n }\n}\n\n/**\n * Publish a single entry (content entry or page).\n */\nasync function publishEntry(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string\n): Promise<PublishResult> {\n try {\n // Set workspace context for RLS\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n\n // Attempt to publish\n const result = await pool.unsafe<{ optimistic_version: number }>(\n `UPDATE ${table}\n SET status = 'published',\n scheduled_at = NULL,\n published_at = now(),\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'draft'\n RETURNING optimistic_version`,\n [entryId]\n );\n\n if (result.length === 0) {\n // Entry was already published or doesn't exist\n return { success: true, entryId, retryable: false };\n }\n\n // A scheduled draft just went live — redeploy the static site so the change\n // reaches <subdomain>.bettercms.site, mirroring the interactive publish path.\n await rebuildAfterPublish(pool, hooks, table, entryId, workspaceId);\n\n // FLO-60/FLO-199: index the freshly published item into Typesense — pages by\n // block content, content entries by their field data.\n if (table === 'pages') {\n await indexPageOnPublish(pool, entryId, workspaceId);\n } else {\n await indexEntryOnPublish(pool, entryId, workspaceId);\n }\n\n return { success: true, entryId, retryable: false };\n } catch (error) {\n return recordPublishFailure(pool, table, entryId, workspaceId, error as Error);\n }\n}\n\n/**\n * The publish lock refused a due entry: an empty visible required field, a unique value another\n * entry holds, or a section zone that isn't ready. Retrying can't fix content, so the schedule\n * ends the way exhausted retries end: `scheduled_at` cleared, `scheduled_retry_count` at its\n * maximum, the entry left a draft. There is no error column to write to (no migration); the\n * caller notifies the author with the reason.\n */\nasync function blockSchedule(pool: Pool, entryId: string, workspaceId: string, reason: string): Promise<void> {\n console.warn(`Scheduled publish blocked for content_entries ${entryId}: ${reason}`);\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n await pool.unsafe(\n `UPDATE content_entries\n SET scheduled_at = NULL,\n scheduled_retry_count = $2\n WHERE id = $1\n AND status = 'draft'`,\n [entryId, MAX_RETRY_COUNT]\n );\n } catch (error) {\n // The next tick finds it due again and re-checks; nothing is published either way.\n console.error(`Failed to end blocked schedule for content_entries ${entryId}:`, (error as Error).message);\n }\n}\n\n/**\n * A publish attempt (or its preflight) failed for a reason that may pass on retry: count it,\n * and after MAX_RETRY_COUNT clear the schedule and alert Sentry.\n */\nasync function recordPublishFailure(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n err: Error\n): Promise<PublishResult> {\n {\n console.error(`Failed to publish ${table} ${entryId}:`, err.message);\n\n // Increment retry count\n try {\n // The preflight path reaches here before publishEntry set this entry's workspace.\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n const retryResult = await pool.unsafe<{ scheduled_retry_count: number }>(\n `UPDATE ${table}\n SET scheduled_retry_count = scheduled_retry_count + 1\n WHERE id = $1\n RETURNING scheduled_retry_count`,\n [entryId]\n );\n\n const retryCount = retryResult[0]?.scheduled_retry_count ?? 0;\n\n if (retryCount >= MAX_RETRY_COUNT) {\n // Max retries reached - clear scheduled_at and alert Sentry\n await pool.unsafe(\n `UPDATE ${table} SET scheduled_at = NULL WHERE id = $1`,\n [entryId]\n );\n\n alertSentryFailure(table, entryId, err.message, retryCount);\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n } catch {\n // Failed to update retry count, don't retry\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n\n // Retryable failure\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: true,\n };\n }\n}\n\n/**\n * Ask the backend to rebuild the project this row belongs to.\n *\n * 🔴 THIS USED TO UPSERT `rebuild_queue` ITSELF, and that is the whole bug. A second copy of\n * the publish funnel only queues our own static build: it never fires the project's deploy\n * targets, the revalidation webhook, or the drain kick — so a page scheduled for 09:00 on a\n * Vercel-hosted project published, and never reached Vercel. The funnel lives in the backend\n * (src/lib/hosting/rebuild-queue.ts `enqueueProjectRebuild`) and is injected, because this\n * package is driver-agnostic (a raw postgres.js handle) while the funnel speaks drizzle.\n *\n * All this still owns is the lookup the caller cannot do: which project the row belongs to.\n * Reads on the handle the caller already set the workspace context on.\n *\n * Best-effort: a failure here must never fail the publish it follows.\n */\nasync function rebuildAfterPublish(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n /** False on the unpublish lane — it rebuilds, but it is not a publish to record. */\n record = true\n): Promise<void> {\n try {\n const [row] = await pool.unsafe<{ project_id: string | null }>(\n `SELECT project_id FROM ${table} WHERE id = $1`,\n [entryId]\n );\n if (!row?.project_id) return; // not part of a project — nothing hosts it\n // Only a PUBLISH goes in the publish history. A scheduled retirement still needs the\n // rebuild below, but recording it would put a row in the timeline saying the page was\n // published at the moment it came down.\n if (record) {\n await hooks.recordPublish?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n });\n }\n // Its own try: a webhook failure must never cost the rebuild, nor fail the publish.\n try {\n await hooks.onChanged?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n reason: record ? 'publish' : 'unpublish',\n });\n } catch (err) {\n console.error(`[publish] change signals failed for ${table} ${entryId}:`, (err as Error).message);\n }\n await hooks.enqueueRebuild(row.project_id, workspaceId);\n } catch (err) {\n console.error(\n `[rebuild] enqueue on scheduled publish failed for ${table} ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-60: upsert a freshly published page into the Typesense `pages` collection,\n * mirroring the interactive publish path (src/routes/admin/pages.ts). Reads the\n * page in a workspace-scoped transaction (RLS), then upserts via the shared\n * @bettercms-ai/db helper (the typesense client is env-driven, not pool-bound).\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexPageOnPublish(\n pool: Pool,\n pageId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n title: string | null;\n slug: string | null;\n block_json: unknown;\n noindex: boolean | null;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, title, slug, block_json, noindex, updated_at FROM pages WHERE id = $1`,\n [pageId]\n );\n });\n\n const page = rows[0];\n if (!page) return;\n\n // A page scheduled while hidden from search goes live hidden: keep it out of site search,\n // as the interactive publish path and list_search_docs (pg.noindex = false) already do.\n if (page.noindex === true) {\n await removePage(String(page.id)).catch(() => {});\n return;\n }\n\n await upsertPage({\n id: String(page.id),\n workspace_id: String(page.workspace_id),\n project_id: page.project_id ?? '',\n type: 'page',\n title: String(page.title ?? ''),\n slug: String(page.slug ?? ''),\n content: extractBlockText((page.block_json as Array<Record<string, unknown>>) ?? []),\n updated_at: page.updated_at ? new Date(page.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for page ${pageId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-199: upsert a freshly published content entry into the Typesense collection,\n * mirroring indexPageOnPublish. Reads the entry in a workspace-scoped transaction\n * (RLS), then upserts via the shared @bettercms-ai/db helper. Title falls back to the\n * slug when the entry has no `data.title`.\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexEntryOnPublish(\n pool: Pool,\n entryId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n slug: string | null;\n data: unknown;\n meta_json: unknown;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, slug, data, meta_json, updated_at FROM content_entries WHERE id = $1`,\n [entryId]\n );\n });\n\n const entry = rows[0];\n if (!entry) return;\n\n // #735: an entry hidden from search stays out of site search when its schedule fires —\n // the same predicate indexEntry and reconcileProject use, so this lane cannot drift from them.\n // Removing rather than just skipping also clears a document an earlier publish left behind.\n if (isEntryNoindex(entry.meta_json)) {\n await removeEntry(String(entry.id)).catch(() => {});\n return;\n }\n\n const data = (entry.data as Record<string, unknown>) ?? {};\n const title = typeof data.title === 'string' && data.title.trim() ? data.title : String(entry.slug ?? '');\n\n await upsertEntry({\n id: String(entry.id),\n workspace_id: String(entry.workspace_id),\n project_id: entry.project_id ?? '',\n type: 'entry',\n title,\n slug: String(entry.slug ?? ''),\n content: extractEntryText(data),\n updated_at: entry.updated_at ? new Date(entry.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for entry ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * Alert Sentry about a persistent publishing failure.\n */\nfunction alertSentryFailure(\n table: string,\n entryId: string,\n error: string,\n retryCount: number\n): void {\n if (Sentry) {\n Sentry.captureMessage(\n `Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId}`,\n {\n level: 'error',\n extra: {\n table,\n entryId,\n error,\n retryCount,\n },\n }\n );\n } else {\n console.error(\n `[ALERT] Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId} - ${error}`\n );\n }\n}\n\n/**\n * Clear all scheduled publishes for an entry (for cancellation).\n *\n * @param pool - PostgreSQL connection pool\n * @param table - Table name (content_entries or pages)\n * @param entryId - Entry ID to clear\n */\nexport async function clearScheduledPublish(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string\n): Promise<void> {\n await pool.unsafe(\n `UPDATE ${table}\n SET scheduled_at = NULL,\n scheduled_retry_count = 0\n WHERE id = $1`,\n [entryId]\n );\n}\n\n/**\n * Get next scheduled publish time (for health checks).\n */\nexport async function getNextScheduledPublish(pool: Pool): Promise<Date | null> {\n const result = await pool.unsafe<{ next_at: Date }>(\n `SELECT MIN(scheduled_at) as next_at\n FROM (\n SELECT scheduled_at FROM content_entries WHERE scheduled_at IS NOT NULL\n UNION ALL\n SELECT scheduled_at FROM pages WHERE scheduled_at IS NOT NULL\n ) t`\n );\n\n return result[0]?.next_at ?? null;\n}"],"mappings":"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","names":[]}
|
package/dist/preview.d.ts
CHANGED
|
@@ -9,12 +9,30 @@ import { JWTPayload } from 'jose';
|
|
|
9
9
|
* FLO-110: Preview token system for sharing unpublished drafts
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The server's cap on any preview or share token (owner decision Q3, PRD invariant 15).
|
|
14
|
+
*
|
|
15
|
+
* `expiresIn` came straight from the request body with nothing above it, so `9999d` minted a
|
|
16
|
+
* token that outlived the content, the campaign and usually the employee — and there was no way
|
|
17
|
+
* to withdraw a single one, because the only lever was bumping the entry's previewTokenVersion,
|
|
18
|
+
* which kills every link to that entry at once.
|
|
19
|
+
*/
|
|
20
|
+
declare const MAX_PREVIEW_EXPIRY_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* A `24h` / `7d` / `60m` duration in milliseconds.
|
|
23
|
+
*
|
|
24
|
+
* Extracted so the CAP and the EXPIRY are read off one parse. Two parsers would be two places to
|
|
25
|
+
* accept `9999d`, and the one that drifted would be the one the clamp used.
|
|
26
|
+
*/
|
|
27
|
+
declare function expiryMs(expiresIn?: string): number;
|
|
12
28
|
interface PreviewTokenPayload extends JWTPayload {
|
|
13
29
|
entryId: string;
|
|
14
30
|
entrySlug: string;
|
|
15
31
|
workspaceId: string;
|
|
16
32
|
previewTokenVersion: number;
|
|
17
33
|
type: 'content-entry' | 'page';
|
|
34
|
+
/** Present on every token minted after 0355; absent on older, still-valid links. */
|
|
35
|
+
jti?: string;
|
|
18
36
|
}
|
|
19
37
|
/**
|
|
20
38
|
* Generate a JWT preview token for a content entry or page.
|
|
@@ -27,7 +45,18 @@ interface PreviewTokenPayload extends JWTPayload {
|
|
|
27
45
|
* @param expiresIn - Token expiry (default: 24h)
|
|
28
46
|
* @param type - Whether this is for a content entry or page
|
|
29
47
|
*/
|
|
30
|
-
declare function generatePreviewToken(entryId: string, entrySlug: string, workspaceId: string, previewTokenVersion: number, secret: string, expiresIn?: string, type?: 'content-entry' | 'page'
|
|
48
|
+
declare function generatePreviewToken(entryId: string, entrySlug: string, workspaceId: string, previewTokenVersion: number, secret: string, expiresIn?: string, type?: 'content-entry' | 'page',
|
|
49
|
+
/**
|
|
50
|
+
* A per-token id, so ONE link can be withdrawn. Without it the only revocation lever is the
|
|
51
|
+
* entry's previewTokenVersion, which kills every link to that entry — the reason a leaked
|
|
52
|
+
* share link used to mean telling everyone else their link had stopped working too.
|
|
53
|
+
*/
|
|
54
|
+
jti?: string,
|
|
55
|
+
/**
|
|
56
|
+
* The content branch (its slug) the entry or page lives on, when not main. A headless front end's
|
|
57
|
+
* draft route reads it (signed, so it can be trusted) to preview the whole site from that branch.
|
|
58
|
+
*/
|
|
59
|
+
branch?: string): Promise<string>;
|
|
31
60
|
/**
|
|
32
61
|
* Verify a JWT preview token.
|
|
33
62
|
*
|
|
@@ -65,4 +94,4 @@ declare function buildPreviewUrl(baseUrl: string, slug: string, token: string):
|
|
|
65
94
|
*/
|
|
66
95
|
declare function nextPreviewTokenVersion(existingStatus: string, newStatus: string | undefined, currentVersion: number): number;
|
|
67
96
|
|
|
68
|
-
export { type PreviewTokenPayload, buildPreviewUrl, calculateExpiry, generatePreviewToken, nextPreviewTokenVersion, verifyPreviewToken };
|
|
97
|
+
export { MAX_PREVIEW_EXPIRY_MS, type PreviewTokenPayload, buildPreviewUrl, calculateExpiry, expiryMs, generatePreviewToken, nextPreviewTokenVersion, verifyPreviewToken };
|
package/dist/preview.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
// src/preview.ts
|
|
2
2
|
import { SignJWT, jwtVerify } from "jose";
|
|
3
3
|
var DEFAULT_EXPIRY = "24h";
|
|
4
|
-
|
|
4
|
+
var MAX_PREVIEW_EXPIRY_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
5
|
+
var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
6
|
+
function expiryMs(expiresIn = DEFAULT_EXPIRY) {
|
|
7
|
+
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
|
8
|
+
if (!match) {
|
|
9
|
+
throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);
|
|
10
|
+
}
|
|
11
|
+
return parseInt(match[1], 10) * UNIT_MS[match[2]];
|
|
12
|
+
}
|
|
13
|
+
async function generatePreviewToken(entryId, entrySlug, workspaceId, previewTokenVersion, secret, expiresIn = DEFAULT_EXPIRY, type = "content-entry", jti, branch) {
|
|
5
14
|
const encoder = new TextEncoder();
|
|
6
15
|
const key = encoder.encode(secret);
|
|
7
|
-
const
|
|
16
|
+
const signer = new SignJWT({
|
|
8
17
|
entryId,
|
|
9
18
|
entrySlug,
|
|
10
19
|
workspaceId,
|
|
11
20
|
previewTokenVersion,
|
|
12
|
-
type
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
type,
|
|
22
|
+
...branch ? { branch } : {}
|
|
23
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn);
|
|
24
|
+
return (jti ? signer.setJti(jti) : signer).sign(key);
|
|
15
25
|
}
|
|
16
26
|
async function verifyPreviewToken(token, secret) {
|
|
17
27
|
const encoder = new TextEncoder();
|
|
@@ -22,28 +32,7 @@ async function verifyPreviewToken(token, secret) {
|
|
|
22
32
|
return payload;
|
|
23
33
|
}
|
|
24
34
|
function calculateExpiry(expiresIn = DEFAULT_EXPIRY) {
|
|
25
|
-
|
|
26
|
-
if (!match) {
|
|
27
|
-
throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);
|
|
28
|
-
}
|
|
29
|
-
const value = parseInt(match[1], 10);
|
|
30
|
-
const unit = match[2];
|
|
31
|
-
const now = /* @__PURE__ */ new Date();
|
|
32
|
-
switch (unit) {
|
|
33
|
-
case "s":
|
|
34
|
-
now.setSeconds(now.getSeconds() + value);
|
|
35
|
-
break;
|
|
36
|
-
case "m":
|
|
37
|
-
now.setMinutes(now.getMinutes() + value);
|
|
38
|
-
break;
|
|
39
|
-
case "h":
|
|
40
|
-
now.setHours(now.getHours() + value);
|
|
41
|
-
break;
|
|
42
|
-
case "d":
|
|
43
|
-
now.setDate(now.getDate() + value);
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
return now.toISOString();
|
|
35
|
+
return new Date(Date.now() + expiryMs(expiresIn)).toISOString();
|
|
47
36
|
}
|
|
48
37
|
function buildPreviewUrl(baseUrl, slug, token) {
|
|
49
38
|
return `${baseUrl}/api/v1/preview/${slug}?token=${encodeURIComponent(token)}`;
|
|
@@ -54,8 +43,10 @@ function nextPreviewTokenVersion(existingStatus, newStatus, currentVersion) {
|
|
|
54
43
|
return publishing || unpublishing ? currentVersion + 1 : currentVersion;
|
|
55
44
|
}
|
|
56
45
|
export {
|
|
46
|
+
MAX_PREVIEW_EXPIRY_MS,
|
|
57
47
|
buildPreviewUrl,
|
|
58
48
|
calculateExpiry,
|
|
49
|
+
expiryMs,
|
|
59
50
|
generatePreviewToken,
|
|
60
51
|
nextPreviewTokenVersion,
|
|
61
52
|
verifyPreviewToken
|
package/dist/preview.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/preview.ts"],"sourcesContent":["/**\n * JWT Preview Token Generation and Verification\n *\n * Uses jose library for JWT handling in Cloudflare Workers.\n * Tokens allow time-limited draft content access without authentication.\n *\n * FLO-110: Preview token system for sharing unpublished drafts\n */\n\nimport { SignJWT, jwtVerify, type JWTPayload } from 'jose';\n\nconst DEFAULT_EXPIRY = '24h'; // Default token expiry of 24 hours\n\nexport interface PreviewTokenPayload extends JWTPayload {\n entryId: string;\n entrySlug: string;\n workspaceId: string;\n previewTokenVersion: number;\n type: 'content-entry' | 'page';\n}\n\n/**\n * Generate a JWT preview token for a content entry or page.\n *\n * @param entryId - The content entry or page ID\n * @param entrySlug - The slug of the entry (used in preview URL)\n * @param workspaceId - The workspace ID (used for RLS enforcement)\n * @param previewTokenVersion - The current preview token version for invalidation\n * @param secret - The PREVIEW_SECRET env var (must be at least 32 chars)\n * @param expiresIn - Token expiry (default: 24h)\n * @param type - Whether this is for a content entry or page\n */\nexport async function generatePreviewToken(\n entryId: string,\n entrySlug: string,\n workspaceId: string,\n previewTokenVersion: number,\n secret: string,\n expiresIn: string = DEFAULT_EXPIRY,\n type: 'content-entry' | 'page' = 'content-entry'\n): Promise<string> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const
|
|
1
|
+
{"version":3,"sources":["../src/preview.ts"],"sourcesContent":["/**\n * JWT Preview Token Generation and Verification\n *\n * Uses jose library for JWT handling in Cloudflare Workers.\n * Tokens allow time-limited draft content access without authentication.\n *\n * FLO-110: Preview token system for sharing unpublished drafts\n */\n\nimport { SignJWT, jwtVerify, type JWTPayload } from 'jose';\n\nconst DEFAULT_EXPIRY = '24h'; // Default token expiry of 24 hours\n\n/**\n * The server's cap on any preview or share token (owner decision Q3, PRD invariant 15).\n *\n * `expiresIn` came straight from the request body with nothing above it, so `9999d` minted a\n * token that outlived the content, the campaign and usually the employee — and there was no way\n * to withdraw a single one, because the only lever was bumping the entry's previewTokenVersion,\n * which kills every link to that entry at once.\n */\nexport const MAX_PREVIEW_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000;\n\nconst UNIT_MS: Record<string, number> = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };\n\n/**\n * A `24h` / `7d` / `60m` duration in milliseconds.\n *\n * Extracted so the CAP and the EXPIRY are read off one parse. Two parsers would be two places to\n * accept `9999d`, and the one that drifted would be the one the clamp used.\n */\nexport function expiryMs(expiresIn: string = DEFAULT_EXPIRY): number {\n const match = expiresIn.match(/^(\\d+)([smhd])$/);\n if (!match) {\n throw new Error(`Invalid expiry format: ${expiresIn}. Use format like '24h', '7d', '60m'`);\n }\n return parseInt(match[1]!, 10) * UNIT_MS[match[2]!]!;\n}\n\nexport interface PreviewTokenPayload extends JWTPayload {\n entryId: string;\n entrySlug: string;\n workspaceId: string;\n previewTokenVersion: number;\n type: 'content-entry' | 'page';\n /** Present on every token minted after 0355; absent on older, still-valid links. */\n jti?: string;\n}\n\n/**\n * Generate a JWT preview token for a content entry or page.\n *\n * @param entryId - The content entry or page ID\n * @param entrySlug - The slug of the entry (used in preview URL)\n * @param workspaceId - The workspace ID (used for RLS enforcement)\n * @param previewTokenVersion - The current preview token version for invalidation\n * @param secret - The PREVIEW_SECRET env var (must be at least 32 chars)\n * @param expiresIn - Token expiry (default: 24h)\n * @param type - Whether this is for a content entry or page\n */\nexport async function generatePreviewToken(\n entryId: string,\n entrySlug: string,\n workspaceId: string,\n previewTokenVersion: number,\n secret: string,\n expiresIn: string = DEFAULT_EXPIRY,\n type: 'content-entry' | 'page' = 'content-entry',\n /**\n * A per-token id, so ONE link can be withdrawn. Without it the only revocation lever is the\n * entry's previewTokenVersion, which kills every link to that entry — the reason a leaked\n * share link used to mean telling everyone else their link had stopped working too.\n */\n jti?: string,\n /**\n * The content branch (its slug) the entry or page lives on, when not main. A headless front end's\n * draft route reads it (signed, so it can be trusted) to preview the whole site from that branch.\n */\n branch?: string,\n): Promise<string> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const signer = new SignJWT({\n entryId,\n entrySlug,\n workspaceId,\n previewTokenVersion,\n type,\n ...(branch ? { branch } : {}),\n })\n .setProtectedHeader({ alg: 'HS256' })\n .setIssuedAt()\n .setExpirationTime(expiresIn);\n\n return (jti ? signer.setJti(jti) : signer).sign(key);\n}\n\n/**\n * Verify a JWT preview token.\n *\n * @param token - The JWT token from query parameter\n * @param secret - The PREVIEW_SECRET env var\n * @returns The decoded payload if valid, throws if invalid\n */\nexport async function verifyPreviewToken(\n token: string,\n secret: string\n): Promise<PreviewTokenPayload> {\n const encoder = new TextEncoder();\n const key = encoder.encode(secret);\n\n const { payload } = await jwtVerify(token, key, {\n algorithms: ['HS256'],\n });\n\n return payload as PreviewTokenPayload;\n}\n\n/**\n * Calculate expiry timestamp for a token.\n *\n * @param expiresIn - Duration string (e.g., '24h', '7d')\n * @returns ISO string of when the token expires\n */\nexport function calculateExpiry(expiresIn: string = DEFAULT_EXPIRY): string {\n return new Date(Date.now() + expiryMs(expiresIn)).toISOString();\n}\n\n/**\n * Build a full preview URL with token embedded.\n *\n * @param baseUrl - The API base URL (e.g., https://api.bettercms.ai)\n * @param slug - The entry slug\n * @param token - The JWT preview token\n */\nexport function buildPreviewUrl(baseUrl: string, slug: string, token: string): string {\n return `${baseUrl}/api/v1/preview/${slug}?token=${encodeURIComponent(token)}`;\n}\n\n/**\n * Decide the entry/page previewTokenVersion after a write.\n *\n * Preview tokens embed previewTokenVersion; bumping it invalidates every live\n * preview link. Bump when the publish boundary is crossed in EITHER direction —\n * publish (→ published) or unpublish (published → draft) — but NOT on\n * content-only edits, so auto-save keeps shared preview links alive.\n *\n * @param existingStatus - The entry's current status before this write\n * @param newStatus - The status in the incoming body, or undefined if unchanged\n * @param currentVersion - The entry's current previewTokenVersion\n */\nexport function nextPreviewTokenVersion(\n existingStatus: string,\n newStatus: string | undefined,\n currentVersion: number,\n): number {\n const publishing = newStatus === 'published';\n const unpublishing = newStatus === 'draft' && existingStatus === 'published';\n return publishing || unpublishing ? currentVersion + 1 : currentVersion;\n}"],"mappings":";AASA,SAAS,SAAS,iBAAkC;AAEpD,IAAM,iBAAiB;AAUhB,IAAM,wBAAwB,KAAK,KAAK,KAAK,KAAK;AAEzD,IAAM,UAAkC,EAAE,GAAG,KAAO,GAAG,KAAQ,GAAG,MAAW,GAAG,MAAW;AAQpF,SAAS,SAAS,YAAoB,gBAAwB;AACnE,QAAM,QAAQ,UAAU,MAAM,iBAAiB;AAC/C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI,MAAM,0BAA0B,SAAS,sCAAsC;AAAA,EAC3F;AACA,SAAO,SAAS,MAAM,CAAC,GAAI,EAAE,IAAI,QAAQ,MAAM,CAAC,CAAE;AACpD;AAuBA,eAAsB,qBACpB,SACA,WACA,aACA,qBACA,QACA,YAAoB,gBACpB,OAAiC,iBAMjC,KAKA,QACiB;AACjB,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,MAAM,QAAQ,OAAO,MAAM;AAEjC,QAAM,SAAS,IAAI,QAAQ;AAAA,IACzB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;AAAA,EAC7B,CAAC,EACE,mBAAmB,EAAE,KAAK,QAAQ,CAAC,EACnC,YAAY,EACZ,kBAAkB,SAAS;AAE9B,UAAQ,MAAM,OAAO,OAAO,GAAG,IAAI,QAAQ,KAAK,GAAG;AACrD;AASA,eAAsB,mBACpB,OACA,QAC8B;AAC9B,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,MAAM,QAAQ,OAAO,MAAM;AAEjC,QAAM,EAAE,QAAQ,IAAI,MAAM,UAAU,OAAO,KAAK;AAAA,IAC9C,YAAY,CAAC,OAAO;AAAA,EACtB,CAAC;AAED,SAAO;AACT;AAQO,SAAS,gBAAgB,YAAoB,gBAAwB;AAC1E,SAAO,IAAI,KAAK,KAAK,IAAI,IAAI,SAAS,SAAS,CAAC,EAAE,YAAY;AAChE;AASO,SAAS,gBAAgB,SAAiB,MAAc,OAAuB;AACpF,SAAO,GAAG,OAAO,mBAAmB,IAAI,UAAU,mBAAmB,KAAK,CAAC;AAC7E;AAcO,SAAS,wBACd,gBACA,WACA,gBACQ;AACR,QAAM,aAAa,cAAc;AACjC,QAAM,eAAe,cAAc,WAAW,mBAAmB;AACjE,SAAO,cAAc,eAAe,iBAAiB,IAAI;AAC3D;","names":[]}
|
package/dist/publish.d.ts
CHANGED
|
@@ -39,6 +39,18 @@ interface PublishHooks {
|
|
|
39
39
|
kind: 'page' | 'entry';
|
|
40
40
|
id: string;
|
|
41
41
|
}): Promise<unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Tell the world the row went live or came down: lifecycle webhooks, the revalidation webhook,
|
|
44
|
+
* the delivery-cache purge. Without it a scheduled publish reached our own build and nothing
|
|
45
|
+
* else, and on a headless project (which never builds) it reached nobody at all.
|
|
46
|
+
*/
|
|
47
|
+
onChanged?(input: {
|
|
48
|
+
projectId: string;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
kind: 'page' | 'entry';
|
|
51
|
+
id: string;
|
|
52
|
+
reason: 'publish' | 'unpublish';
|
|
53
|
+
}): Promise<unknown>;
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
56
|
* The publish lock's verdict on one due content entry, taken when the schedule FIRES (CPO-039).
|
package/dist/publish.js
CHANGED
|
@@ -245,6 +245,17 @@ async function rebuildAfterPublish(pool, hooks, table, entryId, workspaceId, rec
|
|
|
245
245
|
id: entryId
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
|
+
try {
|
|
249
|
+
await hooks.onChanged?.({
|
|
250
|
+
projectId: row.project_id,
|
|
251
|
+
workspaceId,
|
|
252
|
+
kind: table === "pages" ? "page" : "entry",
|
|
253
|
+
id: entryId,
|
|
254
|
+
reason: record ? "publish" : "unpublish"
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`[publish] change signals failed for ${table} ${entryId}:`, err.message);
|
|
258
|
+
}
|
|
248
259
|
await hooks.enqueueRebuild(row.project_id, workspaceId);
|
|
249
260
|
} catch (err) {
|
|
250
261
|
console.error(
|
package/dist/publish.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/publish.ts"],"sourcesContent":["/**\n * Scheduled Publishing Logic\n *\n * Handles publishing of scheduled content entries and pages.\n * Includes retry logic with max 3 attempts before alerting Sentry.\n *\n * FLO-116: Scheduled publishing with automatic retry\n */\n\n/**\n * Minimal structural handle this module uses — a postgres.js pool or a dedicated\n * connection. `unsafe` resolves to the row array; `begin` runs a transaction on one\n * connection. A real postgres.js client satisfies this at runtime (callers pass it\n * through a cast), keeping these query helpers driver-version agnostic.\n */\ninterface Pool {\n unsafe<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;\n begin<T>(fn: (tx: Pool) => Promise<T>): Promise<T>;\n end(): Promise<void>;\n}\nimport { upsertPage, removePage, extractBlockText, upsertEntry, extractEntryText, removeEntry } from '@bettercms-ai/db';\nimport { isEntryNoindex } from '@bettercms-ai/db/entry-noindex';\n\n// Sentry is imported conditionally to avoid issues when not configured\nlet Sentry: typeof import('@sentry/node') | null = null;\n\ntry {\n Sentry = require('@sentry/node');\n} catch {\n // Sentry not configured, continue without it\n}\n\nconst MAX_RETRY_COUNT = 3;\n\n/**\n * Follow-on work a publish triggers that this package deliberately does not own.\n *\n * The scheduled-publish cron runs inside the backend (src/lib/publish/scheduled.ts), which\n * holds the one funnel every publish in the codebase passes through: the platform publishing\n * lock, the project's external deploy targets, the publish backup, the debounced rebuild queue\n * and the drain kick. Injecting it keeps that funnel single-copy instead of re-implemented for\n * the cron runtime, which is how scheduled publishes came to skip custom hosts entirely.\n */\nexport interface PublishHooks {\n /** The backend's `enqueueProjectRebuild(projectId, workspaceId)`. */\n enqueueRebuild(projectId: string, workspaceId: string): Promise<unknown>;\n /**\n * Record the publish in the project's history, so a scheduled publish appears in the timeline\n * beside the ones a human pressed. Optional: the undeployed cron worker cannot reach it.\n */\n recordPublish?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n }): Promise<unknown>;\n}\n\ninterface PublishResult {\n success: boolean;\n entryId: string;\n error?: string;\n retryable: boolean;\n}\n\n/**\n * The publish lock's verdict on one due content entry, taken when the schedule FIRES (CPO-039).\n * The backend supplies it: this package has no model schemas. `detail` rides through to\n * `blocked` untouched, so the caller can tell the author why.\n */\nexport type ScheduledPreflightResult =\n | { ok: true }\n | { ok: false; reason: string; detail?: Record<string, unknown> };\n\nexport type ScheduledPreflight = (entry: { id: string; workspaceId: string }) => Promise<ScheduledPreflightResult>;\n\n/** A due entry the publish lock refused. It stays a draft and its schedule is spent. */\nexport type BlockedSchedule = {\n id: string;\n workspaceId: string;\n reason: string;\n detail?: Record<string, unknown>;\n};\n\n/**\n * Publish all scheduled entries that are due for publication.\n * This is the main function called by the cron worker.\n *\n * @param pool - PostgreSQL connection pool\n * @param opts.preflightEntry - re-runs the publish lock on each due CONTENT ENTRY right before\n * it goes live. The content can change after the schedule was validated (another entry takes\n * the SKU, a required field is emptied through a lane that doesn't validate), and a schedule\n * is a publish on a timer, so it gets the same gate as the button. Absent, nothing is checked.\n * @returns Summary of published entries; `blocked` are the ones the preflight refused (also in `failed`)\n */\nexport async function publishScheduledEntries(\n pool: Pool,\n hooks: PublishHooks,\n opts: { preflightEntry?: ScheduledPreflight } = {}\n): Promise<{\n published: string[];\n failed: string[];\n skipped: string[];\n blocked: BlockedSchedule[];\n}> {\n const now = new Date();\n const result = {\n published: [] as string[],\n failed: [] as string[],\n skipped: [] as string[],\n blocked: [] as BlockedSchedule[],\n };\n\n // Fetch due content entries (scheduled_at <= now, status = draft) ACROSS all workspaces.\n // Via SECURITY DEFINER list_due_scheduled_entries (mig 0056): the scan has no single workspace\n // context, so under the restricted role a direct RLS-gated scan returns 0. Per-workspace\n // context is then set before each publish UPDATE below.\n const entries = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_entries($1)`,\n [now.toISOString()]\n );\n\n // Fetch due pages (same definer-scan rationale).\n const pages = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_pages($1)`,\n [now.toISOString()]\n );\n\n // Process content entries\n for (const entry of entries) {\n if (opts.preflightEntry) {\n let check: ScheduledPreflightResult;\n try {\n check = await opts.preflightEntry({ id: entry.id, workspaceId: entry.workspace_id });\n } catch (error) {\n // The CHECK couldn't run (a DB hiccup), which says nothing about the entry: the\n // ordinary retry path, same as a failed publish UPDATE.\n const failure = await recordPublishFailure(pool, 'content_entries', entry.id, entry.workspace_id, error as Error);\n (failure.retryable ? result.skipped : result.failed).push(entry.id);\n continue;\n }\n if (!check.ok) {\n await blockSchedule(pool, entry.id, entry.workspace_id, check.reason);\n result.blocked.push({\n id: entry.id,\n workspaceId: entry.workspace_id,\n reason: check.reason,\n ...(check.detail ? { detail: check.detail } : {}),\n });\n result.failed.push(entry.id);\n continue;\n }\n }\n\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'content_entries',\n entry.id,\n entry.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(entry.id);\n } else if (pubResult.retryable) {\n result.skipped.push(entry.id);\n } else {\n result.failed.push(entry.id);\n }\n }\n\n // Process pages\n for (const page of pages) {\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'pages',\n page.id,\n page.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(page.id);\n } else if (pubResult.retryable) {\n result.skipped.push(page.id);\n } else {\n result.failed.push(page.id);\n }\n }\n\n // Log summary\n console.log(\n `Scheduled publishing: ${result.published.length} published, ` +\n `${result.skipped.length} skipped (retry), ${result.failed.length} failed ` +\n `(${result.blocked.length} blocked by the publish lock)`\n );\n\n return result;\n}\n\n/**\n * Return every PUBLISHED entry and page whose `unpublish_at` has passed to draft.\n *\n * The mirror of publishScheduledEntries, and deliberately a separate pass rather than a mode\n * flag on it: the two scan disjoint row sets (that one wants drafts, this one wants published\n * rows), and folding them together would mean one loop whose every step asks which direction\n * it is going.\n *\n * No retry counter. `scheduled_retry_count` exists for publishing because publishing does\n * fallible follow-on work — a rebuild enqueue, a search index write. Unpublishing does the\n * same follow-on work, but if it fails the row is ALREADY back to draft, which is the outcome\n * the schedule asked for; retrying a status change that already happened would be a no-op with\n * a counter attached. A failure here is logged and the next tick simply finds nothing due.\n */\nexport async function unpublishDueEntries(pool: Pool, hooks: PublishHooks): Promise<{\n unpublished: string[];\n failed: string[];\n}> {\n const now = new Date();\n const result = { unpublished: [] as string[], failed: [] as string[] };\n\n // Definer-scan for the same reason the publish path uses one: a cron tick has no single\n // workspace context, so an RLS-gated scan under the restricted role returns zero rows and\n // the schedule would silently never fire.\n const rows = await Promise.all(\n (['content_entries', 'pages'] as const).map(async (table) => {\n const fn = table === 'content_entries' ? 'list_due_unpublish_entries' : 'list_due_unpublish_pages';\n const due = await pool.unsafe<{ id: string; workspace_id: string }>(\n `SELECT id, workspace_id FROM ${fn}($1)`,\n [now.toISOString()]\n );\n return due.map((d) => ({ ...d, table }));\n })\n );\n\n for (const row of rows.flat()) {\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [row.workspace_id]);\n\n // Guarded on status = 'published' so a race with a manual unpublish is a no-op rather\n // than a second version bump. unpublish_at is cleared either way: the schedule fired.\n const updated = await pool.unsafe<{ id: string }>(\n `UPDATE ${row.table}\n SET status = 'draft',\n unpublish_at = NULL,\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'published'\n RETURNING id`,\n [row.id]\n );\n\n if (updated.length === 0) {\n // Already unpublished by hand between the scan and now — clear the stale schedule so\n // the next tick does not keep finding it.\n await pool.unsafe(`UPDATE ${row.table} SET unpublish_at = NULL WHERE id = $1`, [row.id]);\n continue;\n }\n\n // The live site still serves the old copy until it rebuilds — the same reason the\n // publish path enqueues one.\n await rebuildAfterPublish(pool, hooks, row.table, row.id, row.workspace_id, false);\n // …and site search still returns it until the document is dropped. Leaving it indexed\n // is the visible half of the bug: the page 404s but the search result still points at it.\n await dropFromSearchIndex(row.id);\n result.unpublished.push(row.id);\n } catch (error) {\n const err = error as Error;\n console.error(`Failed to unpublish ${row.table} ${row.id}:`, err.message);\n alertSentryFailure(row.table, row.id, err.message, 0);\n result.failed.push(row.id);\n }\n }\n\n console.log(\n `Scheduled unpublishing: ${result.unpublished.length} unpublished, ${result.failed.length} failed`\n );\n return result;\n}\n\n/**\n * Drop a document from site search. Entries and pages share one Typesense collection keyed by\n * id, so one call covers both. Best-effort and non-fatal for the same reason the index-on-\n * publish calls are: the status change is the contract, and search is downstream of it — a\n * Typesense outage must not leave a row published when the schedule said to retire it.\n */\nasync function dropFromSearchIndex(id: string): Promise<void> {\n try {\n await removeEntry(id);\n } catch (err) {\n console.error(`[search] drop on scheduled unpublish failed for ${id}:`, (err as Error).message);\n }\n}\n\n/**\n * Publish a single entry (content entry or page).\n */\nasync function publishEntry(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string\n): Promise<PublishResult> {\n try {\n // Set workspace context for RLS\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n\n // Attempt to publish\n const result = await pool.unsafe<{ optimistic_version: number }>(\n `UPDATE ${table}\n SET status = 'published',\n scheduled_at = NULL,\n published_at = now(),\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'draft'\n RETURNING optimistic_version`,\n [entryId]\n );\n\n if (result.length === 0) {\n // Entry was already published or doesn't exist\n return { success: true, entryId, retryable: false };\n }\n\n // A scheduled draft just went live — redeploy the static site so the change\n // reaches <subdomain>.bettercms.site, mirroring the interactive publish path.\n await rebuildAfterPublish(pool, hooks, table, entryId, workspaceId);\n\n // FLO-60/FLO-199: index the freshly published item into Typesense — pages by\n // block content, content entries by their field data.\n if (table === 'pages') {\n await indexPageOnPublish(pool, entryId, workspaceId);\n } else {\n await indexEntryOnPublish(pool, entryId, workspaceId);\n }\n\n return { success: true, entryId, retryable: false };\n } catch (error) {\n return recordPublishFailure(pool, table, entryId, workspaceId, error as Error);\n }\n}\n\n/**\n * The publish lock refused a due entry: an empty visible required field, a unique value another\n * entry holds, or a section zone that isn't ready. Retrying can't fix content, so the schedule\n * ends the way exhausted retries end: `scheduled_at` cleared, `scheduled_retry_count` at its\n * maximum, the entry left a draft. There is no error column to write to (no migration); the\n * caller notifies the author with the reason.\n */\nasync function blockSchedule(pool: Pool, entryId: string, workspaceId: string, reason: string): Promise<void> {\n console.warn(`Scheduled publish blocked for content_entries ${entryId}: ${reason}`);\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n await pool.unsafe(\n `UPDATE content_entries\n SET scheduled_at = NULL,\n scheduled_retry_count = $2\n WHERE id = $1\n AND status = 'draft'`,\n [entryId, MAX_RETRY_COUNT]\n );\n } catch (error) {\n // The next tick finds it due again and re-checks; nothing is published either way.\n console.error(`Failed to end blocked schedule for content_entries ${entryId}:`, (error as Error).message);\n }\n}\n\n/**\n * A publish attempt (or its preflight) failed for a reason that may pass on retry: count it,\n * and after MAX_RETRY_COUNT clear the schedule and alert Sentry.\n */\nasync function recordPublishFailure(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n err: Error\n): Promise<PublishResult> {\n {\n console.error(`Failed to publish ${table} ${entryId}:`, err.message);\n\n // Increment retry count\n try {\n // The preflight path reaches here before publishEntry set this entry's workspace.\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n const retryResult = await pool.unsafe<{ scheduled_retry_count: number }>(\n `UPDATE ${table}\n SET scheduled_retry_count = scheduled_retry_count + 1\n WHERE id = $1\n RETURNING scheduled_retry_count`,\n [entryId]\n );\n\n const retryCount = retryResult[0]?.scheduled_retry_count ?? 0;\n\n if (retryCount >= MAX_RETRY_COUNT) {\n // Max retries reached - clear scheduled_at and alert Sentry\n await pool.unsafe(\n `UPDATE ${table} SET scheduled_at = NULL WHERE id = $1`,\n [entryId]\n );\n\n alertSentryFailure(table, entryId, err.message, retryCount);\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n } catch {\n // Failed to update retry count, don't retry\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n\n // Retryable failure\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: true,\n };\n }\n}\n\n/**\n * Ask the backend to rebuild the project this row belongs to.\n *\n * 🔴 THIS USED TO UPSERT `rebuild_queue` ITSELF, and that is the whole bug. A second copy of\n * the publish funnel only queues our own static build: it never fires the project's deploy\n * targets, the revalidation webhook, or the drain kick — so a page scheduled for 09:00 on a\n * Vercel-hosted project published, and never reached Vercel. The funnel lives in the backend\n * (src/lib/hosting/rebuild-queue.ts `enqueueProjectRebuild`) and is injected, because this\n * package is driver-agnostic (a raw postgres.js handle) while the funnel speaks drizzle.\n *\n * All this still owns is the lookup the caller cannot do: which project the row belongs to.\n * Reads on the handle the caller already set the workspace context on.\n *\n * Best-effort: a failure here must never fail the publish it follows.\n */\nasync function rebuildAfterPublish(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n /** False on the unpublish lane — it rebuilds, but it is not a publish to record. */\n record = true\n): Promise<void> {\n try {\n const [row] = await pool.unsafe<{ project_id: string | null }>(\n `SELECT project_id FROM ${table} WHERE id = $1`,\n [entryId]\n );\n if (!row?.project_id) return; // not part of a project — nothing hosts it\n // Only a PUBLISH goes in the publish history. A scheduled retirement still needs the\n // rebuild below, but recording it would put a row in the timeline saying the page was\n // published at the moment it came down.\n if (record) {\n await hooks.recordPublish?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n });\n }\n await hooks.enqueueRebuild(row.project_id, workspaceId);\n } catch (err) {\n console.error(\n `[rebuild] enqueue on scheduled publish failed for ${table} ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-60: upsert a freshly published page into the Typesense `pages` collection,\n * mirroring the interactive publish path (src/routes/admin/pages.ts). Reads the\n * page in a workspace-scoped transaction (RLS), then upserts via the shared\n * @bettercms-ai/db helper (the typesense client is env-driven, not pool-bound).\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexPageOnPublish(\n pool: Pool,\n pageId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n title: string | null;\n slug: string | null;\n block_json: unknown;\n noindex: boolean | null;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, title, slug, block_json, noindex, updated_at FROM pages WHERE id = $1`,\n [pageId]\n );\n });\n\n const page = rows[0];\n if (!page) return;\n\n // A page scheduled while hidden from search goes live hidden: keep it out of site search,\n // as the interactive publish path and list_search_docs (pg.noindex = false) already do.\n if (page.noindex === true) {\n await removePage(String(page.id)).catch(() => {});\n return;\n }\n\n await upsertPage({\n id: String(page.id),\n workspace_id: String(page.workspace_id),\n project_id: page.project_id ?? '',\n type: 'page',\n title: String(page.title ?? ''),\n slug: String(page.slug ?? ''),\n content: extractBlockText((page.block_json as Array<Record<string, unknown>>) ?? []),\n updated_at: page.updated_at ? new Date(page.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for page ${pageId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-199: upsert a freshly published content entry into the Typesense collection,\n * mirroring indexPageOnPublish. Reads the entry in a workspace-scoped transaction\n * (RLS), then upserts via the shared @bettercms-ai/db helper. Title falls back to the\n * slug when the entry has no `data.title`.\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexEntryOnPublish(\n pool: Pool,\n entryId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n slug: string | null;\n data: unknown;\n meta_json: unknown;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, slug, data, meta_json, updated_at FROM content_entries WHERE id = $1`,\n [entryId]\n );\n });\n\n const entry = rows[0];\n if (!entry) return;\n\n // #735: an entry hidden from search stays out of site search when its schedule fires —\n // the same predicate indexEntry and reconcileProject use, so this lane cannot drift from them.\n // Removing rather than just skipping also clears a document an earlier publish left behind.\n if (isEntryNoindex(entry.meta_json)) {\n await removeEntry(String(entry.id)).catch(() => {});\n return;\n }\n\n const data = (entry.data as Record<string, unknown>) ?? {};\n const title = typeof data.title === 'string' && data.title.trim() ? data.title : String(entry.slug ?? '');\n\n await upsertEntry({\n id: String(entry.id),\n workspace_id: String(entry.workspace_id),\n project_id: entry.project_id ?? '',\n type: 'entry',\n title,\n slug: String(entry.slug ?? ''),\n content: extractEntryText(data),\n updated_at: entry.updated_at ? new Date(entry.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for entry ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * Alert Sentry about a persistent publishing failure.\n */\nfunction alertSentryFailure(\n table: string,\n entryId: string,\n error: string,\n retryCount: number\n): void {\n if (Sentry) {\n Sentry.captureMessage(\n `Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId}`,\n {\n level: 'error',\n extra: {\n table,\n entryId,\n error,\n retryCount,\n },\n }\n );\n } else {\n console.error(\n `[ALERT] Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId} - ${error}`\n );\n }\n}\n\n/**\n * Clear all scheduled publishes for an entry (for cancellation).\n *\n * @param pool - PostgreSQL connection pool\n * @param table - Table name (content_entries or pages)\n * @param entryId - Entry ID to clear\n */\nexport async function clearScheduledPublish(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string\n): Promise<void> {\n await pool.unsafe(\n `UPDATE ${table}\n SET scheduled_at = NULL,\n scheduled_retry_count = 0\n WHERE id = $1`,\n [entryId]\n );\n}\n\n/**\n * Get next scheduled publish time (for health checks).\n */\nexport async function getNextScheduledPublish(pool: Pool): Promise<Date | null> {\n const result = await pool.unsafe<{ next_at: Date }>(\n `SELECT MIN(scheduled_at) as next_at\n FROM (\n SELECT scheduled_at FROM content_entries WHERE scheduled_at IS NOT NULL\n UNION ALL\n SELECT scheduled_at FROM pages WHERE scheduled_at IS NOT NULL\n ) t`\n );\n\n return result[0]?.next_at ?? null;\n}"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/publish.ts"],"sourcesContent":["/**\n * Scheduled Publishing Logic\n *\n * Handles publishing of scheduled content entries and pages.\n * Includes retry logic with max 3 attempts before alerting Sentry.\n *\n * FLO-116: Scheduled publishing with automatic retry\n */\n\n/**\n * Minimal structural handle this module uses — a postgres.js pool or a dedicated\n * connection. `unsafe` resolves to the row array; `begin` runs a transaction on one\n * connection. A real postgres.js client satisfies this at runtime (callers pass it\n * through a cast), keeping these query helpers driver-version agnostic.\n */\ninterface Pool {\n unsafe<T = unknown>(query: string, params?: unknown[]): Promise<T[]>;\n begin<T>(fn: (tx: Pool) => Promise<T>): Promise<T>;\n end(): Promise<void>;\n}\nimport { upsertPage, removePage, extractBlockText, upsertEntry, extractEntryText, removeEntry } from '@bettercms-ai/db';\nimport { isEntryNoindex } from '@bettercms-ai/db/entry-noindex';\n\n// Sentry is imported conditionally to avoid issues when not configured\nlet Sentry: typeof import('@sentry/node') | null = null;\n\ntry {\n Sentry = require('@sentry/node');\n} catch {\n // Sentry not configured, continue without it\n}\n\nconst MAX_RETRY_COUNT = 3;\n\n/**\n * Follow-on work a publish triggers that this package deliberately does not own.\n *\n * The scheduled-publish cron runs inside the backend (src/lib/publish/scheduled.ts), which\n * holds the one funnel every publish in the codebase passes through: the platform publishing\n * lock, the project's external deploy targets, the publish backup, the debounced rebuild queue\n * and the drain kick. Injecting it keeps that funnel single-copy instead of re-implemented for\n * the cron runtime, which is how scheduled publishes came to skip custom hosts entirely.\n */\nexport interface PublishHooks {\n /** The backend's `enqueueProjectRebuild(projectId, workspaceId)`. */\n enqueueRebuild(projectId: string, workspaceId: string): Promise<unknown>;\n /**\n * Record the publish in the project's history, so a scheduled publish appears in the timeline\n * beside the ones a human pressed. Optional: the undeployed cron worker cannot reach it.\n */\n recordPublish?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n }): Promise<unknown>;\n /**\n * Tell the world the row went live or came down: lifecycle webhooks, the revalidation webhook,\n * the delivery-cache purge. Without it a scheduled publish reached our own build and nothing\n * else, and on a headless project (which never builds) it reached nobody at all.\n */\n onChanged?(input: {\n projectId: string;\n workspaceId: string;\n kind: 'page' | 'entry';\n id: string;\n reason: 'publish' | 'unpublish';\n }): Promise<unknown>;\n}\n\ninterface PublishResult {\n success: boolean;\n entryId: string;\n error?: string;\n retryable: boolean;\n}\n\n/**\n * The publish lock's verdict on one due content entry, taken when the schedule FIRES (CPO-039).\n * The backend supplies it: this package has no model schemas. `detail` rides through to\n * `blocked` untouched, so the caller can tell the author why.\n */\nexport type ScheduledPreflightResult =\n | { ok: true }\n | { ok: false; reason: string; detail?: Record<string, unknown> };\n\nexport type ScheduledPreflight = (entry: { id: string; workspaceId: string }) => Promise<ScheduledPreflightResult>;\n\n/** A due entry the publish lock refused. It stays a draft and its schedule is spent. */\nexport type BlockedSchedule = {\n id: string;\n workspaceId: string;\n reason: string;\n detail?: Record<string, unknown>;\n};\n\n/**\n * Publish all scheduled entries that are due for publication.\n * This is the main function called by the cron worker.\n *\n * @param pool - PostgreSQL connection pool\n * @param opts.preflightEntry - re-runs the publish lock on each due CONTENT ENTRY right before\n * it goes live. The content can change after the schedule was validated (another entry takes\n * the SKU, a required field is emptied through a lane that doesn't validate), and a schedule\n * is a publish on a timer, so it gets the same gate as the button. Absent, nothing is checked.\n * @returns Summary of published entries; `blocked` are the ones the preflight refused (also in `failed`)\n */\nexport async function publishScheduledEntries(\n pool: Pool,\n hooks: PublishHooks,\n opts: { preflightEntry?: ScheduledPreflight } = {}\n): Promise<{\n published: string[];\n failed: string[];\n skipped: string[];\n blocked: BlockedSchedule[];\n}> {\n const now = new Date();\n const result = {\n published: [] as string[],\n failed: [] as string[],\n skipped: [] as string[],\n blocked: [] as BlockedSchedule[],\n };\n\n // Fetch due content entries (scheduled_at <= now, status = draft) ACROSS all workspaces.\n // Via SECURITY DEFINER list_due_scheduled_entries (mig 0056): the scan has no single workspace\n // context, so under the restricted role a direct RLS-gated scan returns 0. Per-workspace\n // context is then set before each publish UPDATE below.\n const entries = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_entries($1)`,\n [now.toISOString()]\n );\n\n // Fetch due pages (same definer-scan rationale).\n const pages = await pool.unsafe<{\n id: string;\n workspace_id: string;\n slug: string;\n optimistic_version: number;\n scheduled_retry_count: number;\n }>(\n `SELECT id, workspace_id, slug, optimistic_version, scheduled_retry_count\n FROM list_due_scheduled_pages($1)`,\n [now.toISOString()]\n );\n\n // Process content entries\n for (const entry of entries) {\n if (opts.preflightEntry) {\n let check: ScheduledPreflightResult;\n try {\n check = await opts.preflightEntry({ id: entry.id, workspaceId: entry.workspace_id });\n } catch (error) {\n // The CHECK couldn't run (a DB hiccup), which says nothing about the entry: the\n // ordinary retry path, same as a failed publish UPDATE.\n const failure = await recordPublishFailure(pool, 'content_entries', entry.id, entry.workspace_id, error as Error);\n (failure.retryable ? result.skipped : result.failed).push(entry.id);\n continue;\n }\n if (!check.ok) {\n await blockSchedule(pool, entry.id, entry.workspace_id, check.reason);\n result.blocked.push({\n id: entry.id,\n workspaceId: entry.workspace_id,\n reason: check.reason,\n ...(check.detail ? { detail: check.detail } : {}),\n });\n result.failed.push(entry.id);\n continue;\n }\n }\n\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'content_entries',\n entry.id,\n entry.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(entry.id);\n } else if (pubResult.retryable) {\n result.skipped.push(entry.id);\n } else {\n result.failed.push(entry.id);\n }\n }\n\n // Process pages\n for (const page of pages) {\n const pubResult = await publishEntry(\n pool,\n hooks,\n 'pages',\n page.id,\n page.workspace_id\n );\n\n if (pubResult.success) {\n result.published.push(page.id);\n } else if (pubResult.retryable) {\n result.skipped.push(page.id);\n } else {\n result.failed.push(page.id);\n }\n }\n\n // Log summary\n console.log(\n `Scheduled publishing: ${result.published.length} published, ` +\n `${result.skipped.length} skipped (retry), ${result.failed.length} failed ` +\n `(${result.blocked.length} blocked by the publish lock)`\n );\n\n return result;\n}\n\n/**\n * Return every PUBLISHED entry and page whose `unpublish_at` has passed to draft.\n *\n * The mirror of publishScheduledEntries, and deliberately a separate pass rather than a mode\n * flag on it: the two scan disjoint row sets (that one wants drafts, this one wants published\n * rows), and folding them together would mean one loop whose every step asks which direction\n * it is going.\n *\n * No retry counter. `scheduled_retry_count` exists for publishing because publishing does\n * fallible follow-on work — a rebuild enqueue, a search index write. Unpublishing does the\n * same follow-on work, but if it fails the row is ALREADY back to draft, which is the outcome\n * the schedule asked for; retrying a status change that already happened would be a no-op with\n * a counter attached. A failure here is logged and the next tick simply finds nothing due.\n */\nexport async function unpublishDueEntries(pool: Pool, hooks: PublishHooks): Promise<{\n unpublished: string[];\n failed: string[];\n}> {\n const now = new Date();\n const result = { unpublished: [] as string[], failed: [] as string[] };\n\n // Definer-scan for the same reason the publish path uses one: a cron tick has no single\n // workspace context, so an RLS-gated scan under the restricted role returns zero rows and\n // the schedule would silently never fire.\n const rows = await Promise.all(\n (['content_entries', 'pages'] as const).map(async (table) => {\n const fn = table === 'content_entries' ? 'list_due_unpublish_entries' : 'list_due_unpublish_pages';\n const due = await pool.unsafe<{ id: string; workspace_id: string }>(\n `SELECT id, workspace_id FROM ${fn}($1)`,\n [now.toISOString()]\n );\n return due.map((d) => ({ ...d, table }));\n })\n );\n\n for (const row of rows.flat()) {\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [row.workspace_id]);\n\n // Guarded on status = 'published' so a race with a manual unpublish is a no-op rather\n // than a second version bump. unpublish_at is cleared either way: the schedule fired.\n const updated = await pool.unsafe<{ id: string }>(\n `UPDATE ${row.table}\n SET status = 'draft',\n unpublish_at = NULL,\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'published'\n RETURNING id`,\n [row.id]\n );\n\n if (updated.length === 0) {\n // Already unpublished by hand between the scan and now — clear the stale schedule so\n // the next tick does not keep finding it.\n await pool.unsafe(`UPDATE ${row.table} SET unpublish_at = NULL WHERE id = $1`, [row.id]);\n continue;\n }\n\n // The live site still serves the old copy until it rebuilds — the same reason the\n // publish path enqueues one.\n await rebuildAfterPublish(pool, hooks, row.table, row.id, row.workspace_id, false);\n // …and site search still returns it until the document is dropped. Leaving it indexed\n // is the visible half of the bug: the page 404s but the search result still points at it.\n await dropFromSearchIndex(row.id);\n result.unpublished.push(row.id);\n } catch (error) {\n const err = error as Error;\n console.error(`Failed to unpublish ${row.table} ${row.id}:`, err.message);\n alertSentryFailure(row.table, row.id, err.message, 0);\n result.failed.push(row.id);\n }\n }\n\n console.log(\n `Scheduled unpublishing: ${result.unpublished.length} unpublished, ${result.failed.length} failed`\n );\n return result;\n}\n\n/**\n * Drop a document from site search. Entries and pages share one Typesense collection keyed by\n * id, so one call covers both. Best-effort and non-fatal for the same reason the index-on-\n * publish calls are: the status change is the contract, and search is downstream of it — a\n * Typesense outage must not leave a row published when the schedule said to retire it.\n */\nasync function dropFromSearchIndex(id: string): Promise<void> {\n try {\n await removeEntry(id);\n } catch (err) {\n console.error(`[search] drop on scheduled unpublish failed for ${id}:`, (err as Error).message);\n }\n}\n\n/**\n * Publish a single entry (content entry or page).\n */\nasync function publishEntry(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string\n): Promise<PublishResult> {\n try {\n // Set workspace context for RLS\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n\n // Attempt to publish\n const result = await pool.unsafe<{ optimistic_version: number }>(\n `UPDATE ${table}\n SET status = 'published',\n scheduled_at = NULL,\n published_at = now(),\n optimistic_version = optimistic_version + 1,\n updated_at = now()\n WHERE id = $1\n AND status = 'draft'\n RETURNING optimistic_version`,\n [entryId]\n );\n\n if (result.length === 0) {\n // Entry was already published or doesn't exist\n return { success: true, entryId, retryable: false };\n }\n\n // A scheduled draft just went live — redeploy the static site so the change\n // reaches <subdomain>.bettercms.site, mirroring the interactive publish path.\n await rebuildAfterPublish(pool, hooks, table, entryId, workspaceId);\n\n // FLO-60/FLO-199: index the freshly published item into Typesense — pages by\n // block content, content entries by their field data.\n if (table === 'pages') {\n await indexPageOnPublish(pool, entryId, workspaceId);\n } else {\n await indexEntryOnPublish(pool, entryId, workspaceId);\n }\n\n return { success: true, entryId, retryable: false };\n } catch (error) {\n return recordPublishFailure(pool, table, entryId, workspaceId, error as Error);\n }\n}\n\n/**\n * The publish lock refused a due entry: an empty visible required field, a unique value another\n * entry holds, or a section zone that isn't ready. Retrying can't fix content, so the schedule\n * ends the way exhausted retries end: `scheduled_at` cleared, `scheduled_retry_count` at its\n * maximum, the entry left a draft. There is no error column to write to (no migration); the\n * caller notifies the author with the reason.\n */\nasync function blockSchedule(pool: Pool, entryId: string, workspaceId: string, reason: string): Promise<void> {\n console.warn(`Scheduled publish blocked for content_entries ${entryId}: ${reason}`);\n try {\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n await pool.unsafe(\n `UPDATE content_entries\n SET scheduled_at = NULL,\n scheduled_retry_count = $2\n WHERE id = $1\n AND status = 'draft'`,\n [entryId, MAX_RETRY_COUNT]\n );\n } catch (error) {\n // The next tick finds it due again and re-checks; nothing is published either way.\n console.error(`Failed to end blocked schedule for content_entries ${entryId}:`, (error as Error).message);\n }\n}\n\n/**\n * A publish attempt (or its preflight) failed for a reason that may pass on retry: count it,\n * and after MAX_RETRY_COUNT clear the schedule and alert Sentry.\n */\nasync function recordPublishFailure(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n err: Error\n): Promise<PublishResult> {\n {\n console.error(`Failed to publish ${table} ${entryId}:`, err.message);\n\n // Increment retry count\n try {\n // The preflight path reaches here before publishEntry set this entry's workspace.\n await pool.unsafe('SELECT set_current_workspace($1)', [workspaceId]);\n const retryResult = await pool.unsafe<{ scheduled_retry_count: number }>(\n `UPDATE ${table}\n SET scheduled_retry_count = scheduled_retry_count + 1\n WHERE id = $1\n RETURNING scheduled_retry_count`,\n [entryId]\n );\n\n const retryCount = retryResult[0]?.scheduled_retry_count ?? 0;\n\n if (retryCount >= MAX_RETRY_COUNT) {\n // Max retries reached - clear scheduled_at and alert Sentry\n await pool.unsafe(\n `UPDATE ${table} SET scheduled_at = NULL WHERE id = $1`,\n [entryId]\n );\n\n alertSentryFailure(table, entryId, err.message, retryCount);\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n } catch {\n // Failed to update retry count, don't retry\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: false,\n };\n }\n\n // Retryable failure\n return {\n success: false,\n entryId,\n error: err.message,\n retryable: true,\n };\n }\n}\n\n/**\n * Ask the backend to rebuild the project this row belongs to.\n *\n * 🔴 THIS USED TO UPSERT `rebuild_queue` ITSELF, and that is the whole bug. A second copy of\n * the publish funnel only queues our own static build: it never fires the project's deploy\n * targets, the revalidation webhook, or the drain kick — so a page scheduled for 09:00 on a\n * Vercel-hosted project published, and never reached Vercel. The funnel lives in the backend\n * (src/lib/hosting/rebuild-queue.ts `enqueueProjectRebuild`) and is injected, because this\n * package is driver-agnostic (a raw postgres.js handle) while the funnel speaks drizzle.\n *\n * All this still owns is the lookup the caller cannot do: which project the row belongs to.\n * Reads on the handle the caller already set the workspace context on.\n *\n * Best-effort: a failure here must never fail the publish it follows.\n */\nasync function rebuildAfterPublish(\n pool: Pool,\n hooks: PublishHooks,\n table: 'content_entries' | 'pages',\n entryId: string,\n workspaceId: string,\n /** False on the unpublish lane — it rebuilds, but it is not a publish to record. */\n record = true\n): Promise<void> {\n try {\n const [row] = await pool.unsafe<{ project_id: string | null }>(\n `SELECT project_id FROM ${table} WHERE id = $1`,\n [entryId]\n );\n if (!row?.project_id) return; // not part of a project — nothing hosts it\n // Only a PUBLISH goes in the publish history. A scheduled retirement still needs the\n // rebuild below, but recording it would put a row in the timeline saying the page was\n // published at the moment it came down.\n if (record) {\n await hooks.recordPublish?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n });\n }\n // Its own try: a webhook failure must never cost the rebuild, nor fail the publish.\n try {\n await hooks.onChanged?.({\n projectId: row.project_id,\n workspaceId,\n kind: table === 'pages' ? 'page' : 'entry',\n id: entryId,\n reason: record ? 'publish' : 'unpublish',\n });\n } catch (err) {\n console.error(`[publish] change signals failed for ${table} ${entryId}:`, (err as Error).message);\n }\n await hooks.enqueueRebuild(row.project_id, workspaceId);\n } catch (err) {\n console.error(\n `[rebuild] enqueue on scheduled publish failed for ${table} ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-60: upsert a freshly published page into the Typesense `pages` collection,\n * mirroring the interactive publish path (src/routes/admin/pages.ts). Reads the\n * page in a workspace-scoped transaction (RLS), then upserts via the shared\n * @bettercms-ai/db helper (the typesense client is env-driven, not pool-bound).\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexPageOnPublish(\n pool: Pool,\n pageId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n title: string | null;\n slug: string | null;\n block_json: unknown;\n noindex: boolean | null;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, title, slug, block_json, noindex, updated_at FROM pages WHERE id = $1`,\n [pageId]\n );\n });\n\n const page = rows[0];\n if (!page) return;\n\n // A page scheduled while hidden from search goes live hidden: keep it out of site search,\n // as the interactive publish path and list_search_docs (pg.noindex = false) already do.\n if (page.noindex === true) {\n await removePage(String(page.id)).catch(() => {});\n return;\n }\n\n await upsertPage({\n id: String(page.id),\n workspace_id: String(page.workspace_id),\n project_id: page.project_id ?? '',\n type: 'page',\n title: String(page.title ?? ''),\n slug: String(page.slug ?? ''),\n content: extractBlockText((page.block_json as Array<Record<string, unknown>>) ?? []),\n updated_at: page.updated_at ? new Date(page.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for page ${pageId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * FLO-199: upsert a freshly published content entry into the Typesense collection,\n * mirroring indexPageOnPublish. Reads the entry in a workspace-scoped transaction\n * (RLS), then upserts via the shared @bettercms-ai/db helper. Title falls back to the\n * slug when the entry has no `data.title`.\n *\n * Best-effort: a search-index failure must never fail the publish it follows.\n */\nasync function indexEntryOnPublish(\n pool: Pool,\n entryId: string,\n workspaceId: string\n): Promise<void> {\n try {\n const rows = await pool.begin(async (tx: Pool) => {\n await tx.unsafe(`SELECT set_config('app.current_workspace_id', $1, true)`, [workspaceId]);\n return tx.unsafe<{\n id: string;\n workspace_id: string;\n project_id: string | null;\n slug: string | null;\n data: unknown;\n meta_json: unknown;\n updated_at: Date | null;\n }>(\n `SELECT id, workspace_id, project_id, slug, data, meta_json, updated_at FROM content_entries WHERE id = $1`,\n [entryId]\n );\n });\n\n const entry = rows[0];\n if (!entry) return;\n\n // #735: an entry hidden from search stays out of site search when its schedule fires —\n // the same predicate indexEntry and reconcileProject use, so this lane cannot drift from them.\n // Removing rather than just skipping also clears a document an earlier publish left behind.\n if (isEntryNoindex(entry.meta_json)) {\n await removeEntry(String(entry.id)).catch(() => {});\n return;\n }\n\n const data = (entry.data as Record<string, unknown>) ?? {};\n const title = typeof data.title === 'string' && data.title.trim() ? data.title : String(entry.slug ?? '');\n\n await upsertEntry({\n id: String(entry.id),\n workspace_id: String(entry.workspace_id),\n project_id: entry.project_id ?? '',\n type: 'entry',\n title,\n slug: String(entry.slug ?? ''),\n content: extractEntryText(data),\n updated_at: entry.updated_at ? new Date(entry.updated_at).getTime() : Date.now(),\n });\n } catch (err) {\n console.error(\n `[search] index on scheduled publish failed for entry ${entryId}:`,\n (err as Error).message\n );\n }\n}\n\n/**\n * Alert Sentry about a persistent publishing failure.\n */\nfunction alertSentryFailure(\n table: string,\n entryId: string,\n error: string,\n retryCount: number\n): void {\n if (Sentry) {\n Sentry.captureMessage(\n `Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId}`,\n {\n level: 'error',\n extra: {\n table,\n entryId,\n error,\n retryCount,\n },\n }\n );\n } else {\n console.error(\n `[ALERT] Scheduled publish failed after ${MAX_RETRY_COUNT} retries: ${table} ${entryId} - ${error}`\n );\n }\n}\n\n/**\n * Clear all scheduled publishes for an entry (for cancellation).\n *\n * @param pool - PostgreSQL connection pool\n * @param table - Table name (content_entries or pages)\n * @param entryId - Entry ID to clear\n */\nexport async function clearScheduledPublish(\n pool: Pool,\n table: 'content_entries' | 'pages',\n entryId: string\n): Promise<void> {\n await pool.unsafe(\n `UPDATE ${table}\n SET scheduled_at = NULL,\n scheduled_retry_count = 0\n WHERE id = $1`,\n [entryId]\n );\n}\n\n/**\n * Get next scheduled publish time (for health checks).\n */\nexport async function getNextScheduledPublish(pool: Pool): Promise<Date | null> {\n const result = await pool.unsafe<{ next_at: Date }>(\n `SELECT MIN(scheduled_at) as next_at\n FROM (\n SELECT scheduled_at FROM content_entries WHERE scheduled_at IS NOT NULL\n UNION ALL\n SELECT scheduled_at FROM pages WHERE scheduled_at IS NOT NULL\n ) t`\n );\n\n return result[0]?.next_at ?? null;\n}"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/content",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared content publishing utilities for BetterCMS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,9 +38,6 @@
|
|
|
38
38
|
"files": [
|
|
39
39
|
"dist"
|
|
40
40
|
],
|
|
41
|
-
"publishConfig": {
|
|
42
|
-
"access": "restricted"
|
|
43
|
-
},
|
|
44
41
|
"scripts": {
|
|
45
42
|
"build": "tsup"
|
|
46
43
|
},
|