@growth-labs/cms 0.8.2 → 0.8.4
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/engine/ai-dek-notifications.d.ts +2 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -1
- package/dist/engine/ai-dek-notifications.js +11 -2
- package/dist/engine/ai-dek-notifications.js.map +1 -1
- package/dist/engine/ai-dek.d.ts +23 -0
- package/dist/engine/ai-dek.d.ts.map +1 -1
- package/dist/engine/ai-dek.js +94 -31
- package/dist/engine/ai-dek.js.map +1 -1
- package/dist/engine/foundry-dispatch.d.ts +13 -3
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +115 -39
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +55 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +187 -57
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/fronts-publish.d.ts +11 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -1
- package/dist/engine/fronts-publish.js +135 -10
- package/dist/engine/fronts-publish.js.map +1 -1
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/podcast-source.d.ts +3 -2
- package/dist/engine/podcast-source.d.ts.map +1 -1
- package/dist/engine/podcast-source.js +6 -4
- package/dist/engine/podcast-source.js.map +1 -1
- package/dist/engine/publication-current-guard.d.ts +7 -0
- package/dist/engine/publication-current-guard.d.ts.map +1 -1
- package/dist/engine/publication-current-guard.js +66 -0
- package/dist/engine/publication-current-guard.js.map +1 -1
- package/dist/engine/publication.d.ts +30 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +306 -34
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +32 -19
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/soft-delete.d.ts +3 -1
- package/dist/engine/soft-delete.d.ts.map +1 -1
- package/dist/engine/soft-delete.js +21 -5
- package/dist/engine/soft-delete.js.map +1 -1
- package/dist/engine/video-source.d.ts +4 -2
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +7 -4
- package/dist/engine/video-source.js.map +1 -1
- package/dist/routes/content.d.ts +14 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +56 -28
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +6 -0
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +2 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -1
- package/dist/routes/fronts-publish.js +178 -2
- package/dist/routes/fronts-publish.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +42 -42
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +8 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/schema.d.ts +48 -48
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +7 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/fronts/schedule.d.ts +2 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -1
- package/dist/ui/api/fronts/schedule.js +2 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +2 -2
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/migrations/0030_media_publication_guard.sql +5 -0
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +19 -2
- package/src/engine/ai-dek.ts +131 -40
- package/src/engine/foundry-dispatch.ts +189 -49
- package/src/engine/fronts-publish-intent.ts +295 -67
- package/src/engine/fronts-publish.ts +167 -11
- package/src/engine/index.ts +4 -0
- package/src/engine/podcast-source.ts +6 -4
- package/src/engine/publication-current-guard.ts +86 -0
- package/src/engine/publication.ts +411 -59
- package/src/engine/publisher.ts +37 -17
- package/src/engine/soft-delete.ts +28 -8
- package/src/engine/video-source.ts +7 -4
- package/src/routes/content.ts +78 -35
- package/src/routes/context.ts +6 -0
- package/src/routes/fronts-publish.ts +213 -2
- package/src/schema/migrations.ts +8 -0
- package/src/schema/types.ts +6 -0
- package/src/ui/api/_content-config.ts +15 -0
- package/src/ui/api/fronts/schedule.ts +4 -0
- package/src/ui/editor/ContentForm.tsx +4 -5
|
@@ -6,6 +6,8 @@ export interface DekFailureNotificationInput {
|
|
|
6
6
|
title: string;
|
|
7
7
|
reason: string;
|
|
8
8
|
userId?: string | null;
|
|
9
|
+
/** Stable callback/attempt identity for replay-safe background notifications. */
|
|
10
|
+
occurrenceId?: string;
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Create unread, in-app notifications when publication ships without a dek.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-dek-notifications.d.ts","sourceRoot":"","sources":["../../src/engine/ai-dek-notifications.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"ai-dek-notifications.d.ts","sourceRoot":"","sources":["../../src/engine/ai-dek-notifications.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAazC,MAAM,WAAW,2BAA2B;IAC3C,EAAE,EAAE,UAAU,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;GAIG;AACH,wBAAsB,6BAA6B,CAClD,KAAK,EAAE,2BAA2B,GAChC,OAAO,CAAC,MAAM,CAAC,CAmCjB"}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
async function deterministicNotificationId(occurrenceId, userId) {
|
|
2
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(['ai-dek-failed.v1', occurrenceId, userId])));
|
|
3
|
+
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
4
|
+
return `ai-dek-failed:${hex}`;
|
|
5
|
+
}
|
|
1
6
|
/**
|
|
2
7
|
* Create unread, in-app notifications when publication ships without a dek.
|
|
3
8
|
* Interactive publishes target the actor; background publishes target every
|
|
@@ -22,11 +27,15 @@ export async function createDekFailureNotifications(input) {
|
|
|
22
27
|
const reason = input.reason.trim().slice(0, 160) || 'unknown error';
|
|
23
28
|
const body = `Content was published without a subheading: “${input.title}”. AI generation failed (${reason}). Open the item to add one manually.`;
|
|
24
29
|
for (const userId of [...new Set(userIds)]) {
|
|
30
|
+
const notificationId = input.occurrenceId
|
|
31
|
+
? await deterministicNotificationId(input.occurrenceId, userId)
|
|
32
|
+
: crypto.randomUUID();
|
|
25
33
|
await input.db
|
|
26
34
|
.prepare(`INSERT INTO cms_notifications
|
|
27
35
|
(id, workspace_id, user_id, kind, body, created_at)
|
|
28
|
-
VALUES (?, ?, ?, 'ai_dek_failed', ?, unixepoch())
|
|
29
|
-
|
|
36
|
+
VALUES (?, ?, ?, 'ai_dek_failed', ?, unixepoch())
|
|
37
|
+
ON CONFLICT(id) DO NOTHING`)
|
|
38
|
+
.bind(notificationId, input.workspaceId, userId, body)
|
|
30
39
|
.run();
|
|
31
40
|
}
|
|
32
41
|
return new Set(userIds).size;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-dek-notifications.js","sourceRoot":"","sources":["../../src/engine/ai-dek-notifications.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ai-dek-notifications.js","sourceRoot":"","sources":["../../src/engine/ai-dek-notifications.ts"],"names":[],"mappings":"AAEA,KAAK,UAAU,2BAA2B,CAAC,YAAoB,EAAE,MAAc;IAC9E,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CACxC,SAAS,EACT,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,kBAAkB,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,CACpF,CAAA;IACD,MAAM,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAChG,EAAE,CACF,CAAA;IACD,OAAO,iBAAiB,GAAG,EAAE,CAAA;AAC9B,CAAC;AAaD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,6BAA6B,CAClD,KAAkC;IAElC,IAAI,OAAiB,CAAA;IACrB,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QAClB,OAAO,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;IACzB,CAAC;SAAM,CAAC;QACP,MAAM,IAAI,GAAG,MAAM,KAAK,CAAC,EAAE;aACzB,OAAO,CACP;;;;0BAIsB,CACtB;aACA,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC;aACvB,GAAG,EAAuB,CAAA;QAC5B,OAAO,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACzD,CAAC;IAED,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,eAAe,CAAA;IACnE,MAAM,IAAI,GAAG,gDAAgD,KAAK,CAAC,KAAK,4BAA4B,MAAM,uCAAuC,CAAA;IACjJ,KAAK,MAAM,MAAM,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;QAC5C,MAAM,cAAc,GAAG,KAAK,CAAC,YAAY;YACxC,CAAC,CAAC,MAAM,2BAA2B,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;YAC/D,CAAC,CAAC,MAAM,CAAC,UAAU,EAAE,CAAA;QACtB,MAAM,KAAK,CAAC,EAAE;aACZ,OAAO,CACP;;;gCAG4B,CAC5B;aACA,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,CAAC;aACrD,GAAG,EAAE,CAAA;IACR,CAAC;IACD,OAAO,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAA;AAC7B,CAAC"}
|
package/dist/engine/ai-dek.d.ts
CHANGED
|
@@ -26,7 +26,30 @@ export type GenerateContentDekResult = {
|
|
|
26
26
|
written: false;
|
|
27
27
|
reason: 'not_found';
|
|
28
28
|
};
|
|
29
|
+
export type GenerateContentDekSuggestionResult = {
|
|
30
|
+
status: 'generated';
|
|
31
|
+
dek: string;
|
|
32
|
+
} | {
|
|
33
|
+
status: 'unavailable';
|
|
34
|
+
dek: null;
|
|
35
|
+
reason: string;
|
|
36
|
+
};
|
|
29
37
|
export declare function loadDekGenerationContext(db: D1Database, contentId: string): Promise<DekGenerationContext | null>;
|
|
38
|
+
/** Build the prompt context from an already-authorized publication snapshot. */
|
|
39
|
+
export declare function loadDekGenerationContextFromSnapshot(db: D1Database, snapshot: Record<string, unknown>): Promise<DekGenerationContext | null>;
|
|
40
|
+
/** Dispatch and normalize a dek suggestion without creating any D1 write continuation. */
|
|
41
|
+
export declare function generateContentDekSuggestion(input: {
|
|
42
|
+
context: DekGenerationContext;
|
|
43
|
+
llm: LlmDispatch;
|
|
44
|
+
}): Promise<GenerateContentDekSuggestionResult>;
|
|
45
|
+
/** Apply a generated suggestion only while its captured editor/publication state is current. */
|
|
46
|
+
export declare function applyGeneratedContentDek(input: {
|
|
47
|
+
db: D1Database;
|
|
48
|
+
contentId: string;
|
|
49
|
+
dek: string;
|
|
50
|
+
context: DekGenerationContext;
|
|
51
|
+
expectedPublicationSnapshot?: Record<string, unknown>;
|
|
52
|
+
}): Promise<GenerateContentDekResult>;
|
|
30
53
|
export declare function generateContentDek(input: {
|
|
31
54
|
db: D1Database;
|
|
32
55
|
contentId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-dek.d.ts","sourceRoot":"","sources":["../../src/engine/ai-dek.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"ai-dek.d.ts","sourceRoot":"","sources":["../../src/engine/ai-dek.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAGxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAMzC,OAAO,EAAE,KAAK,iBAAiB,EAAkB,MAAM,gBAAgB,CAAA;AAEvE,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,MAAM,WAAW,oBAAoB;IACpC,IAAI,EAAE,iBAAiB,CAAA;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,EAAE,CAAA;CACnB;AAED,MAAM,MAAM,wBAAwB,GACjC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GACnD;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,KAAK,CAAA;CAAE,GACjD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpE;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,CAAA;AAE1E,MAAM,MAAM,kCAAkC,GAC3C;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAmDvD,wBAAsB,wBAAwB,CAC7C,EAAE,EAAE,UAAU,EACd,SAAS,EAAE,MAAM,GACf,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAQtC;AAED,gFAAgF;AAChF,wBAAsB,oCAAoC,CACzD,EAAE,EAAE,UAAU,EACd,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAgBtC;AAUD,0FAA0F;AAC1F,wBAAsB,4BAA4B,CAAC,KAAK,EAAE;IACzD,OAAO,EAAE,oBAAoB,CAAA;IAC7B,GAAG,EAAE,WAAW,CAAA;CAChB,GAAG,OAAO,CAAC,kCAAkC,CAAC,CAuB9C;AAED,gGAAgG;AAChG,wBAAsB,wBAAwB,CAAC,KAAK,EAAE;IACrD,EAAE,EAAE,UAAU,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,oBAAoB,CAAA;IAC7B,2BAA2B,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACrD,GAAG,OAAO,CAAC,wBAAwB,CAAC,CA4CpC;AAED,wBAAsB,kBAAkB,CAAC,KAAK,EAAE;IAC/C,EAAE,EAAE,UAAU,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,GAAG,EAAE,WAAW,CAAA;CAChB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAqBpC"}
|
package/dist/engine/ai-dek.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dekPrompt } from './ai-prompts.js';
|
|
2
2
|
import { applyAiWrite, parseLockedFields } from './ai-writeback.js';
|
|
3
|
+
import { CURRENT_PUBLICATION_GUARD, currentPublicationSqlGuard, snapshotRecord, } from './publication-current-guard.js';
|
|
3
4
|
import { getContentItem } from './publisher.js';
|
|
4
5
|
export const DEK_EXEMPLAR_LIMIT = 6;
|
|
5
6
|
async function loadTypeSource(db, item) {
|
|
@@ -24,13 +25,7 @@ async function loadTypeSource(db, item) {
|
|
|
24
25
|
.first();
|
|
25
26
|
return (row?.body_markdown || row?.body_html || row?.subtitle || '').trim();
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
-
const item = await getContentItem(db, contentId);
|
|
29
|
-
if (!item)
|
|
30
|
-
return null;
|
|
31
|
-
const typeSource = await loadTypeSource(db, item);
|
|
32
|
-
const description = item.description?.trim() ?? '';
|
|
33
|
-
const source = [description, typeSource].filter(Boolean).join('\n\n');
|
|
28
|
+
async function loadExemplars(db, item) {
|
|
34
29
|
const rows = await db
|
|
35
30
|
.prepare(`SELECT title, excerpt
|
|
36
31
|
FROM content_items
|
|
@@ -43,10 +38,36 @@ export async function loadDekGenerationContext(db, contentId) {
|
|
|
43
38
|
COALESCE(published_at, 0) DESC,
|
|
44
39
|
id ASC
|
|
45
40
|
LIMIT ?`)
|
|
46
|
-
.bind(
|
|
41
|
+
.bind(item.id, item.type, DEK_EXEMPLAR_LIMIT)
|
|
47
42
|
.all();
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
return (rows.results ?? []).map((row) => `Headline: ${row.title}\nDek: ${row.excerpt.trim()}`);
|
|
44
|
+
}
|
|
45
|
+
export async function loadDekGenerationContext(db, contentId) {
|
|
46
|
+
const item = await getContentItem(db, contentId);
|
|
47
|
+
if (!item)
|
|
48
|
+
return null;
|
|
49
|
+
const typeSource = await loadTypeSource(db, item);
|
|
50
|
+
const description = item.description?.trim() ?? '';
|
|
51
|
+
const source = [description, typeSource].filter(Boolean).join('\n\n');
|
|
52
|
+
return { item, source, exemplars: await loadExemplars(db, item) };
|
|
53
|
+
}
|
|
54
|
+
/** Build the prompt context from an already-authorized publication snapshot. */
|
|
55
|
+
export async function loadDekGenerationContextFromSnapshot(db, snapshot) {
|
|
56
|
+
const item = snapshotRecord(snapshot.item);
|
|
57
|
+
if (!item.id || !item.type || !item.title)
|
|
58
|
+
return null;
|
|
59
|
+
const content = snapshotRecord(snapshot.content);
|
|
60
|
+
const typeSource = item.type === 'video'
|
|
61
|
+
? String(content.script ?? '').trim()
|
|
62
|
+
: item.type === 'podcast'
|
|
63
|
+
? String(content.transcript ?? '').trim()
|
|
64
|
+
: String(content.body_markdown || content.body_html || content.subtitle || '').trim();
|
|
65
|
+
const description = item.description?.trim() ?? '';
|
|
66
|
+
return {
|
|
67
|
+
item,
|
|
68
|
+
source: [description, typeSource].filter(Boolean).join('\n\n'),
|
|
69
|
+
exemplars: await loadExemplars(db, item),
|
|
70
|
+
};
|
|
50
71
|
}
|
|
51
72
|
function normalizeDek(text) {
|
|
52
73
|
return text
|
|
@@ -55,45 +76,87 @@ function normalizeDek(text) {
|
|
|
55
76
|
.replace(/\s+/g, ' ')
|
|
56
77
|
.trim();
|
|
57
78
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!context) {
|
|
61
|
-
return { status: 'not_found', dek: null, written: false, reason: 'not_found' };
|
|
62
|
-
}
|
|
79
|
+
/** Dispatch and normalize a dek suggestion without creating any D1 write continuation. */
|
|
80
|
+
export async function generateContentDekSuggestion(input) {
|
|
63
81
|
const result = await input.llm.dispatch({
|
|
64
82
|
...dekPrompt({
|
|
65
|
-
title: context.item.title,
|
|
66
|
-
source: context.source,
|
|
67
|
-
exemplars: context.exemplars,
|
|
83
|
+
title: input.context.item.title,
|
|
84
|
+
source: input.context.source,
|
|
85
|
+
exemplars: input.context.exemplars,
|
|
68
86
|
}),
|
|
69
87
|
stream: false,
|
|
70
88
|
});
|
|
71
89
|
if (result instanceof ReadableStream) {
|
|
72
|
-
return { status: 'unavailable', dek: null,
|
|
90
|
+
return { status: 'unavailable', dek: null, reason: 'stream_result' };
|
|
73
91
|
}
|
|
74
92
|
if (result.status === 'unavailable') {
|
|
75
93
|
return {
|
|
76
94
|
status: 'unavailable',
|
|
77
95
|
dek: null,
|
|
78
|
-
written: false,
|
|
79
96
|
reason: result.reason ?? 'unavailable',
|
|
80
97
|
};
|
|
81
98
|
}
|
|
82
99
|
const dek = normalizeDek(result.text);
|
|
83
|
-
if (!dek)
|
|
84
|
-
return { status: 'unavailable', dek: null,
|
|
85
|
-
}
|
|
100
|
+
if (!dek)
|
|
101
|
+
return { status: 'unavailable', dek: null, reason: 'empty_result' };
|
|
102
|
+
return { status: 'generated', dek };
|
|
103
|
+
}
|
|
104
|
+
/** Apply a generated suggestion only while its captured editor/publication state is current. */
|
|
105
|
+
export async function applyGeneratedContentDek(input) {
|
|
86
106
|
const write = applyAiWrite({
|
|
87
|
-
locked: parseLockedFields(context.item.ai_locked_fields),
|
|
107
|
+
locked: parseLockedFields(input.context.item.ai_locked_fields),
|
|
88
108
|
field: 'excerpt',
|
|
89
|
-
value: dek,
|
|
109
|
+
value: input.dek,
|
|
90
110
|
});
|
|
91
111
|
if (!write.written)
|
|
92
|
-
return { status: 'locked', dek, written: false };
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
return { status: 'locked', dek: input.dek, written: false };
|
|
113
|
+
const capturedItem = input.context.item;
|
|
114
|
+
const guardPayload = input.expectedPublicationSnapshot
|
|
115
|
+
? JSON.stringify({
|
|
116
|
+
...input.expectedPublicationSnapshot,
|
|
117
|
+
publication_guard: CURRENT_PUBLICATION_GUARD,
|
|
118
|
+
})
|
|
119
|
+
: null;
|
|
120
|
+
const publicationPredicate = guardPayload
|
|
121
|
+
? `AND EXISTS (
|
|
122
|
+
SELECT 1 FROM (SELECT ? AS payload_json) dek_expected
|
|
123
|
+
WHERE ${currentPublicationSqlGuard('dek_expected')}
|
|
124
|
+
)`
|
|
125
|
+
: '';
|
|
126
|
+
const statement = input.db
|
|
127
|
+
.prepare(`UPDATE content_items
|
|
128
|
+
SET excerpt = ?, updated_at = unixepoch()
|
|
129
|
+
WHERE id = ?
|
|
130
|
+
AND canonical_version IS ?
|
|
131
|
+
AND excerpt IS ?
|
|
132
|
+
AND ai_locked_fields IS ?
|
|
133
|
+
${publicationPredicate}`)
|
|
134
|
+
.bind(input.dek, input.contentId, capturedItem.canonical_version, capturedItem.excerpt, capturedItem.ai_locked_fields, ...(guardPayload ? [guardPayload] : []));
|
|
135
|
+
const result = await statement.run();
|
|
136
|
+
if (result.meta?.changes !== 1) {
|
|
137
|
+
return { status: 'unavailable', dek: null, written: false, reason: 'content_changed' };
|
|
138
|
+
}
|
|
139
|
+
return { status: 'generated', dek: input.dek, written: true };
|
|
140
|
+
}
|
|
141
|
+
export async function generateContentDek(input) {
|
|
142
|
+
const context = await loadDekGenerationContext(input.db, input.contentId);
|
|
143
|
+
if (!context) {
|
|
144
|
+
return { status: 'not_found', dek: null, written: false, reason: 'not_found' };
|
|
145
|
+
}
|
|
146
|
+
const suggestion = await generateContentDekSuggestion({ context, llm: input.llm });
|
|
147
|
+
if (suggestion.status === 'unavailable') {
|
|
148
|
+
return {
|
|
149
|
+
status: 'unavailable',
|
|
150
|
+
dek: null,
|
|
151
|
+
written: false,
|
|
152
|
+
reason: suggestion.reason,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return applyGeneratedContentDek({
|
|
156
|
+
db: input.db,
|
|
157
|
+
contentId: input.contentId,
|
|
158
|
+
dek: suggestion.dek,
|
|
159
|
+
context,
|
|
160
|
+
});
|
|
98
161
|
}
|
|
99
162
|
//# sourceMappingURL=ai-dek.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-dek.js","sourceRoot":"","sources":["../../src/engine/ai-dek.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAEnE,OAAO,EAA0B,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAEvE,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"ai-dek.js","sourceRoot":"","sources":["../../src/engine/ai-dek.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,8 +2,10 @@ import type { FoundryDispatch } from '../providers/types.js';
|
|
|
2
2
|
import type { D1Database } from './d1.js';
|
|
3
3
|
export type ProcessingState = 'pending' | 'queued' | 'fetching' | 'transcribing' | 'transcoding' | 'ready' | 'failed';
|
|
4
4
|
export interface DispatchResult {
|
|
5
|
-
|
|
5
|
+
/** Podcast capture reuse has no persisted correlation to return. */
|
|
6
|
+
correlationId: string | null;
|
|
6
7
|
triggerToken: string;
|
|
8
|
+
skipped?: boolean;
|
|
7
9
|
}
|
|
8
10
|
export declare class FoundryDispatchPrerequisiteError extends Error {
|
|
9
11
|
readonly code: "missing_duration";
|
|
@@ -21,16 +23,24 @@ export interface CallbackInput {
|
|
|
21
23
|
description?: string | null;
|
|
22
24
|
excerpt?: string | null;
|
|
23
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Mint a fresh source identity for the caller's source-snapshot CAS. Capture
|
|
28
|
+
* time records initiation, not worker admission; this helper does not dispatch
|
|
29
|
+
* work or change readiness. The raw token stays in the private capture record.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createFoundrySourceCaptureToken(capturedAtSeconds?: number): string;
|
|
24
32
|
/**
|
|
25
33
|
* Dispatch a video or podcast content item to Foundry via the injected hook.
|
|
26
|
-
* Mints a fresh `processing_trigger_token` (UUID), calls
|
|
34
|
+
* Mints a fresh versioned `processing_trigger_token` (capture time + UUID), calls
|
|
27
35
|
* `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
|
|
28
36
|
* `processing_correlation_id` + advances to `'queued'` in either
|
|
29
37
|
* `video_content` or `podcast_content`.
|
|
30
38
|
*
|
|
31
39
|
* Throws if the item is not a video/podcast.
|
|
32
40
|
*/
|
|
33
|
-
export declare function dispatchToFoundry(db: D1Database, contentId: string, hook: Pick<FoundryDispatch, 'dispatchVideo'
|
|
41
|
+
export declare function dispatchToFoundry(db: D1Database, contentId: string, hook: Pick<FoundryDispatch, 'dispatchVideo'>, options?: {
|
|
42
|
+
ifUncaptured?: boolean;
|
|
43
|
+
}): Promise<DispatchResult>;
|
|
34
44
|
/**
|
|
35
45
|
* Apply a Foundry callback event to the state machine.
|
|
36
46
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"foundry-dispatch.d.ts","sourceRoot":"","sources":["../../src/engine/foundry-dispatch.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,KAAK,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"foundry-dispatch.d.ts","sourceRoot":"","sources":["../../src/engine/foundry-dispatch.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,KAAK,EAAE,UAAU,EAAiC,MAAM,SAAS,CAAA;AAcxE,MAAM,MAAM,eAAe,GACxB,SAAS,GACT,QAAQ,GACR,UAAU,GACV,cAAc,GACd,aAAa,GACb,OAAO,GACP,QAAQ,CAAA;AAIX,MAAM,WAAW,cAAc;IAC9B,oEAAoE;IACpE,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,qBAAa,gCAAiC,SAAQ,KAAK;IAC1D,QAAQ,CAAC,IAAI,EAAG,kBAAkB,CAAS;gBAE/B,SAAS,EAAE,MAAM;CAI7B;AAED,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,YAAY,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,eAAe,CAAA;IAC1B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvB;AAwCD;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC9C,iBAAiB,SAAgC,GAC/C,MAAM,CAKR;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACtC,EAAE,EAAE,UAAU,EACd,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,eAAe,CAAC,EAC5C,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GACtC,OAAO,CAAC,cAAc,CAAC,CA8QzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CA8KjG;AAED;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GACf,OAAO,CAAC,OAAO,CAAC,CA8ClB"}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// ready | failed. Advances are driven by HMAC-verified callback events keyed
|
|
10
10
|
// on `processing_trigger_token` and deduplicated on `event_id`.
|
|
11
11
|
import { validPodcastSourceUrl } from './podcast-source.js';
|
|
12
|
+
import { mediaPublicationCaptureGuard } from './publication-current-guard.js';
|
|
12
13
|
const TERMINAL_STATES = ['ready', 'failed'];
|
|
13
14
|
export class FoundryDispatchPrerequisiteError extends Error {
|
|
14
15
|
code = 'missing_duration';
|
|
@@ -49,18 +50,31 @@ function inferVideoSourceKind(sourceUrl) {
|
|
|
49
50
|
}
|
|
50
51
|
return 'http_url';
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Mint a fresh source identity for the caller's source-snapshot CAS. Capture
|
|
55
|
+
* time records initiation, not worker admission; this helper does not dispatch
|
|
56
|
+
* work or change readiness. The raw token stays in the private capture record.
|
|
57
|
+
*/
|
|
58
|
+
export function createFoundrySourceCaptureToken(capturedAtSeconds = Math.floor(Date.now() / 1000)) {
|
|
59
|
+
if (!Number.isSafeInteger(capturedAtSeconds) || capturedAtSeconds <= 0) {
|
|
60
|
+
throw new RangeError('Capture time must be positive safe integer Unix seconds');
|
|
61
|
+
}
|
|
62
|
+
return `fronts-source-v2:${capturedAtSeconds}:${crypto.randomUUID()}`;
|
|
63
|
+
}
|
|
52
64
|
/**
|
|
53
65
|
* Dispatch a video or podcast content item to Foundry via the injected hook.
|
|
54
|
-
* Mints a fresh `processing_trigger_token` (UUID), calls
|
|
66
|
+
* Mints a fresh versioned `processing_trigger_token` (capture time + UUID), calls
|
|
55
67
|
* `hook.dispatchVideo({ contentId, sourceUrl, kind, durationSeconds })`, and writes
|
|
56
68
|
* `processing_correlation_id` + advances to `'queued'` in either
|
|
57
69
|
* `video_content` or `podcast_content`.
|
|
58
70
|
*
|
|
59
71
|
* Throws if the item is not a video/podcast.
|
|
60
72
|
*/
|
|
61
|
-
export async function dispatchToFoundry(db, contentId, hook) {
|
|
73
|
+
export async function dispatchToFoundry(db, contentId, hook, options = {}) {
|
|
62
74
|
const content = await db
|
|
63
|
-
.prepare(
|
|
75
|
+
.prepare(`SELECT id, type, slug, title, description, canonical_version, status, deleted_at,
|
|
76
|
+
visibility, published_revision_id, published_at, publish_at, publish_tz,
|
|
77
|
+
fronts_publish_status, fronts_publish_trigger_token FROM content_items WHERE id = ? LIMIT 1`)
|
|
64
78
|
.bind(contentId)
|
|
65
79
|
.first();
|
|
66
80
|
if (!content) {
|
|
@@ -68,7 +82,7 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
68
82
|
}
|
|
69
83
|
// Determine which media table owns this content id.
|
|
70
84
|
const videoRow = await db
|
|
71
|
-
.prepare('SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind FROM video_content WHERE content_id = ?')
|
|
85
|
+
.prepare('SELECT content_id, video_id, duration_seconds, processing_source_url, processing_source_kind, processing_trigger_token, processing_correlation_id FROM video_content WHERE content_id = ?')
|
|
72
86
|
.bind(contentId)
|
|
73
87
|
.first();
|
|
74
88
|
let table;
|
|
@@ -79,6 +93,18 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
79
93
|
let durationSeconds;
|
|
80
94
|
let podcastSnapshot = null;
|
|
81
95
|
if (videoRow) {
|
|
96
|
+
if (content.type !== 'video' || content.deleted_at != null || content.status === 'archived') {
|
|
97
|
+
throw new Error(`dispatchToFoundry: video ${contentId} is inactive`);
|
|
98
|
+
}
|
|
99
|
+
if (options.ifUncaptured &&
|
|
100
|
+
videoRow.processing_trigger_token &&
|
|
101
|
+
videoRow.processing_correlation_id) {
|
|
102
|
+
return {
|
|
103
|
+
correlationId: videoRow.processing_correlation_id,
|
|
104
|
+
triggerToken: videoRow.processing_trigger_token,
|
|
105
|
+
skipped: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
82
108
|
table = 'video_content';
|
|
83
109
|
sourceUrl = videoRow.processing_source_url?.trim() ?? '';
|
|
84
110
|
if (!sourceUrl) {
|
|
@@ -102,12 +128,17 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
102
128
|
throw new Error(`dispatchToFoundry: podcast ${contentId} is inactive`);
|
|
103
129
|
}
|
|
104
130
|
podcastSnapshot = podcastRow;
|
|
105
|
-
|
|
131
|
+
const selectedSource = podcastRow.processing_source_url?.trim();
|
|
132
|
+
if (selectedSource &&
|
|
133
|
+
(podcastRow.processing_source_kind !== 'https' || !validPodcastSourceUrl(selectedSource))) {
|
|
134
|
+
throw new Error(`dispatchToFoundry: content ${contentId} has an invalid podcast source URL`);
|
|
135
|
+
}
|
|
136
|
+
sourceUrl = selectedSource || podcastRow.audio_r2_key?.trim() || '';
|
|
106
137
|
if (!sourceUrl) {
|
|
107
|
-
throw new Error(`dispatchToFoundry: content ${contentId} has no podcast audio R2 key`);
|
|
138
|
+
throw new Error(`dispatchToFoundry: content ${contentId} has no podcast source or audio R2 key`);
|
|
108
139
|
}
|
|
109
140
|
kind = 'podcast';
|
|
110
|
-
sourceKind = 'r2_key';
|
|
141
|
+
sourceKind = selectedSource ? 'https' : 'r2_key';
|
|
111
142
|
videoId = normalizeVideoId(content.slug, content.slug);
|
|
112
143
|
durationSeconds = podcastRow.duration_seconds;
|
|
113
144
|
}
|
|
@@ -116,8 +147,21 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
116
147
|
durationSeconds <= 0) {
|
|
117
148
|
throw new FoundryDispatchPrerequisiteError(contentId);
|
|
118
149
|
}
|
|
119
|
-
|
|
150
|
+
if (options.ifUncaptured &&
|
|
151
|
+
podcastSnapshot?.processing_trigger_token?.trim() &&
|
|
152
|
+
podcastSnapshot.processing_source_kind === 'https' &&
|
|
153
|
+
validPodcastSourceUrl(podcastSnapshot.processing_source_url)) {
|
|
154
|
+
// Source edits clear the token. This source/token pair is the CMS's
|
|
155
|
+
// committed capture evidence; a metadata save need not call the host again.
|
|
156
|
+
return {
|
|
157
|
+
correlationId: null,
|
|
158
|
+
triggerToken: podcastSnapshot.processing_trigger_token,
|
|
159
|
+
skipped: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const triggerToken = createFoundrySourceCaptureToken();
|
|
120
163
|
const processingTriggerTokenHash = `sha256:${await sha256Hex(triggerToken)}`;
|
|
164
|
+
const publicationGuard = mediaPublicationCaptureGuard(content, processingTriggerTokenHash);
|
|
121
165
|
const { correlationId, podcastSource } = await hook.dispatchVideo({
|
|
122
166
|
contentId,
|
|
123
167
|
sourceUrl,
|
|
@@ -131,13 +175,16 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
131
175
|
});
|
|
132
176
|
const now = Math.floor(Date.now() / 1000);
|
|
133
177
|
if (table === 'video_content') {
|
|
134
|
-
|
|
178
|
+
if (!videoRow)
|
|
179
|
+
throw new Error('Video dispatch snapshot is missing');
|
|
180
|
+
const written = await db
|
|
135
181
|
.prepare(`UPDATE video_content
|
|
136
182
|
SET processing_state = 'queued',
|
|
137
183
|
video_id = ?,
|
|
138
184
|
processing_source_kind = ?,
|
|
139
185
|
processing_correlation_id = ?,
|
|
140
186
|
processing_trigger_token = ?,
|
|
187
|
+
processing_publication_guard = ?,
|
|
141
188
|
processing_started_at = ?,
|
|
142
189
|
processing_completed_at = NULL,
|
|
143
190
|
processing_last_event_at = NULL,
|
|
@@ -148,9 +195,19 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
148
195
|
transcript_url = NULL,
|
|
149
196
|
hls_manifest_url = NULL,
|
|
150
197
|
hls_poster_url = NULL
|
|
151
|
-
WHERE content_id =
|
|
152
|
-
|
|
198
|
+
WHERE content_id = ? AND video_id IS ? AND duration_seconds IS ?
|
|
199
|
+
AND processing_source_url IS ? AND processing_source_kind IS ?
|
|
200
|
+
AND processing_trigger_token IS ? AND processing_correlation_id IS ?
|
|
201
|
+
AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = video_content.content_id
|
|
202
|
+
AND ci.type = 'video' AND ci.slug = ? AND ci.canonical_version = ?
|
|
203
|
+
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
204
|
+
AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
|
|
205
|
+
AND ci.publish_at IS ? AND ci.publish_tz IS ?
|
|
206
|
+
AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`)
|
|
207
|
+
.bind(videoId, sourceKind, correlationId, triggerToken, publicationGuard, now, contentId, videoRow.video_id, videoRow.duration_seconds, videoRow.processing_source_url, videoRow.processing_source_kind, videoRow.processing_trigger_token, videoRow.processing_correlation_id, content.slug, content.canonical_version, content.status, content.visibility, content.published_revision_id, content.published_at, content.publish_at, content.publish_tz, content.fronts_publish_status, content.fronts_publish_trigger_token)
|
|
153
208
|
.run();
|
|
209
|
+
if (written.meta?.changes !== 1)
|
|
210
|
+
throw new Error('Video source changed before dispatch acknowledgement');
|
|
154
211
|
}
|
|
155
212
|
else {
|
|
156
213
|
if (!podcastSnapshot)
|
|
@@ -171,13 +228,16 @@ export async function dispatchToFoundry(db, contentId, hook) {
|
|
|
171
228
|
// source/audio/editor snapshot is still current. No stale acknowledgement
|
|
172
229
|
// can restore a replaced source or overwrite a newer processing token.
|
|
173
230
|
const written = await db
|
|
174
|
-
.prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token =
|
|
231
|
+
.prepare(`UPDATE podcast_content SET processing_source_url = ?, processing_source_kind = ?, processing_trigger_token = ?, processing_publication_guard = ?, processing_state = 'queued', transcript = ''
|
|
175
232
|
WHERE content_id = ? AND audio_r2_key IS ? AND duration_seconds IS ? AND transcript IS ?
|
|
176
233
|
AND processing_source_url IS ? AND processing_source_kind IS ? AND processing_trigger_token IS ?
|
|
177
234
|
AND EXISTS (SELECT 1 FROM content_items ci WHERE ci.id = podcast_content.content_id
|
|
178
235
|
AND ci.type = 'podcast' AND ci.slug = ? AND ci.canonical_version = ?
|
|
179
|
-
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
180
|
-
|
|
236
|
+
AND ci.status = ? AND ci.deleted_at IS NULL
|
|
237
|
+
AND ci.visibility IS ? AND ci.published_revision_id IS ? AND ci.published_at IS ?
|
|
238
|
+
AND ci.publish_at IS ? AND ci.publish_tz IS ?
|
|
239
|
+
AND ci.fronts_publish_status IS ? AND ci.fronts_publish_trigger_token IS ?)`)
|
|
240
|
+
.bind(capturedUrl, capturedKind, triggerToken, publicationGuard, contentId, podcastSnapshot.audio_r2_key, podcastSnapshot.duration_seconds, podcastSnapshot.transcript, podcastSnapshot.processing_source_url, podcastSnapshot.processing_source_kind, podcastSnapshot.processing_trigger_token, content.slug, content.canonical_version, content.status, content.visibility, content.published_revision_id, content.published_at, content.publish_at, content.publish_tz, content.fronts_publish_status, content.fronts_publish_trigger_token)
|
|
181
241
|
.run();
|
|
182
242
|
if (written.meta?.changes !== 1)
|
|
183
243
|
throw new Error('Podcast source changed before dispatch acknowledgement');
|
|
@@ -227,12 +287,17 @@ export async function applyFoundryCallback(db, input) {
|
|
|
227
287
|
if (!videoRow && !podcastRow) {
|
|
228
288
|
return false; // no matching job for this trigger token
|
|
229
289
|
}
|
|
230
|
-
//
|
|
290
|
+
// A failed event is consumed atomically with terminal state and guard revocation.
|
|
291
|
+
// Otherwise a transient media write failure would make its retry a no-op.
|
|
231
292
|
const now = Math.floor(Date.now() / 1000);
|
|
232
|
-
|
|
293
|
+
const eventStatement = db
|
|
233
294
|
.prepare('INSERT INTO foundry_callback_events (event_id, event_kind, correlation_id, received_at) VALUES (?, ?, ?, ?)')
|
|
234
|
-
.bind(eventId, eventKind, correlationId, now)
|
|
235
|
-
|
|
295
|
+
.bind(eventId, eventKind, correlationId, now);
|
|
296
|
+
if (nextState !== 'failed')
|
|
297
|
+
await eventStatement.run();
|
|
298
|
+
const applyMediaUpdate = async (statement) => nextState === 'failed'
|
|
299
|
+
? (await db.batch([statement, eventStatement]))[0]
|
|
300
|
+
: await statement.run();
|
|
236
301
|
// Advance the state machine.
|
|
237
302
|
const isTerminal = TERMINAL_STATES.includes(nextState);
|
|
238
303
|
if (podcastRow) {
|
|
@@ -240,61 +305,72 @@ export async function applyFoundryCallback(db, input) {
|
|
|
240
305
|
const nextDurationSeconds = typeof durationSeconds === 'number' && Number.isFinite(durationSeconds)
|
|
241
306
|
? Math.max(0, Math.floor(durationSeconds))
|
|
242
307
|
: null;
|
|
243
|
-
|
|
244
|
-
await db
|
|
308
|
+
{
|
|
309
|
+
const updated = await applyMediaUpdate(db
|
|
245
310
|
.prepare(`UPDATE podcast_content
|
|
246
|
-
SET
|
|
311
|
+
SET processing_state = ?, processing_publication_guard = CASE WHEN ? = 'failed' THEN NULL ELSE processing_publication_guard END,
|
|
312
|
+
transcript = CASE WHEN ? THEN ? ELSE transcript END,
|
|
247
313
|
duration_seconds = CASE WHEN ? THEN ? ELSE duration_seconds END
|
|
248
|
-
WHERE content_id = ?`)
|
|
249
|
-
.bind(nextTranscript !== null ? 1 : 0, nextTranscript, nextDurationSeconds !== null ? 1 : 0, nextDurationSeconds, podcastRow.content_id)
|
|
250
|
-
|
|
314
|
+
WHERE content_id = ? AND processing_trigger_token = ?`)
|
|
315
|
+
.bind(nextState, nextState, nextTranscript !== null ? 1 : 0, nextTranscript, nextDurationSeconds !== null ? 1 : 0, nextDurationSeconds, podcastRow.content_id, triggerToken));
|
|
316
|
+
if (updated.meta?.changes !== 1)
|
|
317
|
+
return false;
|
|
251
318
|
}
|
|
252
319
|
const nextDescription = typeof description === 'string' && description.trim() ? description.trim() : null;
|
|
253
320
|
const nextExcerpt = typeof excerpt === 'string' && excerpt.trim() ? excerpt.trim() : null;
|
|
254
321
|
if (nextDescription !== null || nextExcerpt !== null) {
|
|
255
|
-
await db
|
|
322
|
+
const updated = await db
|
|
256
323
|
.prepare(`UPDATE content_items
|
|
257
324
|
SET description = CASE WHEN ? THEN ? ELSE description END,
|
|
258
325
|
excerpt = CASE WHEN ? THEN ? ELSE excerpt END,
|
|
259
326
|
updated_at = unixepoch()
|
|
260
|
-
WHERE id =
|
|
261
|
-
|
|
327
|
+
WHERE id = ? AND EXISTS (
|
|
328
|
+
SELECT 1 FROM podcast_content p WHERE p.content_id = content_items.id
|
|
329
|
+
AND p.processing_trigger_token = ?
|
|
330
|
+
)`)
|
|
331
|
+
.bind(nextDescription !== null ? 1 : 0, nextDescription, nextExcerpt !== null ? 1 : 0, nextExcerpt, podcastRow.content_id, triggerToken)
|
|
262
332
|
.run();
|
|
333
|
+
if (updated.meta?.changes !== 1)
|
|
334
|
+
return false;
|
|
263
335
|
}
|
|
264
|
-
return
|
|
336
|
+
return Boolean(await db
|
|
337
|
+
.prepare('SELECT content_id FROM podcast_content WHERE content_id = ? AND processing_trigger_token = ?')
|
|
338
|
+
.bind(podcastRow.content_id, triggerToken)
|
|
339
|
+
.first());
|
|
265
340
|
}
|
|
266
341
|
if (!videoRow)
|
|
267
342
|
return false;
|
|
268
343
|
const row = videoRow;
|
|
344
|
+
let updated;
|
|
269
345
|
if (nextState === 'failed') {
|
|
270
|
-
await db
|
|
346
|
+
updated = await applyMediaUpdate(db
|
|
271
347
|
.prepare(`UPDATE video_content
|
|
272
348
|
SET processing_state = ?,
|
|
349
|
+
processing_publication_guard = NULL,
|
|
273
350
|
processing_last_event_at = ?,
|
|
274
351
|
processing_completed_at = ?,
|
|
275
352
|
processing_error = ?
|
|
276
|
-
WHERE content_id = ?`)
|
|
277
|
-
.bind(nextState, now, now, error ?? null, row.content_id)
|
|
278
|
-
.run();
|
|
353
|
+
WHERE content_id = ? AND processing_trigger_token = ?`)
|
|
354
|
+
.bind(nextState, now, now, error ?? null, row.content_id, triggerToken));
|
|
279
355
|
}
|
|
280
356
|
else if (isTerminal) {
|
|
281
357
|
// 'ready'
|
|
282
|
-
await db
|
|
358
|
+
updated = await db
|
|
283
359
|
.prepare(`UPDATE video_content
|
|
284
360
|
SET processing_state = ?,
|
|
285
361
|
processing_last_event_at = ?,
|
|
286
362
|
processing_completed_at = ?
|
|
287
|
-
WHERE content_id = ?`)
|
|
288
|
-
.bind(nextState, now, now, row.content_id)
|
|
363
|
+
WHERE content_id = ? AND processing_trigger_token = ?`)
|
|
364
|
+
.bind(nextState, now, now, row.content_id, triggerToken)
|
|
289
365
|
.run();
|
|
290
366
|
}
|
|
291
367
|
else {
|
|
292
|
-
await db
|
|
293
|
-
.prepare('UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ?')
|
|
294
|
-
.bind(nextState, now, row.content_id)
|
|
368
|
+
updated = await db
|
|
369
|
+
.prepare('UPDATE video_content SET processing_state = ?, processing_last_event_at = ? WHERE content_id = ? AND processing_trigger_token = ?')
|
|
370
|
+
.bind(nextState, now, row.content_id, triggerToken)
|
|
295
371
|
.run();
|
|
296
372
|
}
|
|
297
|
-
return
|
|
373
|
+
return updated.meta?.changes === 1;
|
|
298
374
|
}
|
|
299
375
|
/**
|
|
300
376
|
* Verify a HMAC-SHA256 signature over `rawBody` using `secret`.
|