@agent-native/core 0.101.7 → 0.101.10
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +22 -0
- package/corpus/core/docs/content/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
- package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
- package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
- package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
- package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
- package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
- package/corpus/core/src/server/auth.ts +25 -1
- package/corpus/core/src/server/builder-browser.ts +353 -1
- package/corpus/core/src/server/core-routes-plugin.ts +601 -223
- package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
- package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
- package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
- package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
- package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
- package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
- package/corpus/templates/content/AGENTS.md +3 -2
- package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
- package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
- package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
- package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
- package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
- package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
- package/corpus/templates/content/actions/_database-utils.ts +89 -12
- package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
- package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
- package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
- package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
- package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
- package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
- package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
- package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
- package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
- package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
- package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
- package/corpus/templates/content/app/global.css +18 -1
- package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -0
- package/corpus/templates/content/app/i18n-data.ts +287 -0
- package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
- package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
- package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
- package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
- package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
- package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
- package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
- package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
- package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
- package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
- package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
- package/corpus/templates/content/parity/matrix.md +27 -25
- package/corpus/templates/content/parity/matrix.ts +50 -2
- package/corpus/templates/content/server/db/schema.ts +47 -0
- package/corpus/templates/content/server/plugins/auth.ts +1 -0
- package/corpus/templates/content/server/plugins/db.ts +53 -0
- package/corpus/templates/content/shared/api.ts +70 -1
- package/corpus/templates/content/shared/builder-mdx.ts +705 -15
- package/corpus/templates/content/shared/nfm.ts +3 -3
- package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
- package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
- package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
- package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
- package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
- package/corpus/templates/design/app/i18n/en-US.ts +1 -0
- package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
- package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
- package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
- package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
- package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
- package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
- package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
- package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
- package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
- package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
- package/corpus/templates/design/shared/review-anchor.ts +48 -0
- package/corpus/templates/plan/server/plan-mdx.ts +53 -7
- package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
- package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
- package/dist/client/review/ReviewCommentComposer.js +3 -3
- package/dist/client/review/ReviewCommentComposer.js.map +1 -1
- package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
- package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
- package/dist/client/review/ReviewThreadPanel.js +6 -4
- package/dist/client/review/ReviewThreadPanel.js.map +1 -1
- package/dist/client/settings/SettingsPanel.d.ts +3 -0
- package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
- package/dist/client/settings/SettingsPanel.js +11 -4
- package/dist/client/settings/SettingsPanel.js.map +1 -1
- package/dist/client/settings/useBuilderStatus.d.ts +8 -4
- package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
- package/dist/client/settings/useBuilderStatus.js +19 -6
- package/dist/client/settings/useBuilderStatus.js.map +1 -1
- package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
- package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
- package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/auth.d.ts +1 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +19 -2
- package/dist/server/auth.js.map +1 -1
- package/dist/server/builder-browser.d.ts +78 -0
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +246 -1
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +41 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +260 -14
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/docs/content/deployment.mdx +9 -7
- package/docs/content/locales/ar-SA/deployment.mdx +9 -7
- package/docs/content/locales/de-DE/deployment.mdx +9 -7
- package/docs/content/locales/es-ES/deployment.mdx +9 -7
- package/docs/content/locales/fr-FR/deployment.mdx +9 -7
- package/docs/content/locales/hi-IN/deployment.mdx +9 -7
- package/docs/content/locales/ja-JP/deployment.mdx +9 -7
- package/docs/content/locales/ko-KR/deployment.mdx +9 -7
- package/docs/content/locales/pt-BR/deployment.mdx +9 -7
- package/docs/content/locales/zh-CN/deployment.mdx +9 -7
- package/docs/content/locales/zh-TW/deployment.mdx +9 -7
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
2
3
|
import { getDialect, type Dialect } from "@agent-native/core/db";
|
|
3
4
|
import { and, asc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
4
5
|
|
|
@@ -38,6 +39,7 @@ import type {
|
|
|
38
39
|
import {
|
|
39
40
|
builderBlocksHash,
|
|
40
41
|
builderBlocksToReadableMarkdown,
|
|
42
|
+
builderEntryBlocks,
|
|
41
43
|
builderEntryToReadableMdxBundle,
|
|
42
44
|
builderEntryToMdxBundle,
|
|
43
45
|
builderReadableBodyToBuilderBlocks,
|
|
@@ -64,6 +66,7 @@ import {
|
|
|
64
66
|
import {
|
|
65
67
|
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
66
68
|
BUILDER_CMS_BODY_CONTENT_KEY,
|
|
69
|
+
BUILDER_CMS_BODY_LAST_UPDATED_KEY,
|
|
67
70
|
BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
|
|
68
71
|
BUILDER_CMS_BODY_READABLE_MAP_KEY,
|
|
69
72
|
BUILDER_CMS_BODY_SIDECARS_KEY,
|
|
@@ -73,6 +76,7 @@ import {
|
|
|
73
76
|
builderCmsSourceFieldKey,
|
|
74
77
|
builderCmsSourceMetadata,
|
|
75
78
|
builderCmsSourceRowIdentity,
|
|
79
|
+
builderCmsSourceRowIdentityState,
|
|
76
80
|
type BuilderCmsSourceEntry,
|
|
77
81
|
type ExistingBuilderSourceRowIdentity,
|
|
78
82
|
} from "./_builder-cms-source-adapter.js";
|
|
@@ -103,6 +107,16 @@ type ContentDatabaseSourceChangeReviewRowDb =
|
|
|
103
107
|
type ContentDatabaseSourceExecutionRowDb =
|
|
104
108
|
typeof schema.contentDatabaseSourceExecutions.$inferSelect;
|
|
105
109
|
|
|
110
|
+
function stableBuilderImportId(
|
|
111
|
+
prefix: "builder-doc" | "builder-item" | "builder-row",
|
|
112
|
+
parts: readonly string[],
|
|
113
|
+
) {
|
|
114
|
+
return `${prefix}_${createHash("sha256")
|
|
115
|
+
.update(parts.join("\0"))
|
|
116
|
+
.digest("base64url")
|
|
117
|
+
.slice(0, 24)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
const DEFAULT_SOURCE_CAPABILITIES: ContentDatabaseSourceCapabilities = {
|
|
107
121
|
canRefresh: true,
|
|
108
122
|
canCreateChangeSets: true,
|
|
@@ -319,7 +333,7 @@ export function serializeSourceField(
|
|
|
319
333
|
};
|
|
320
334
|
}
|
|
321
335
|
|
|
322
|
-
function serializeSourceRowRecord(
|
|
336
|
+
export function serializeSourceRowRecord(
|
|
323
337
|
row: ContentDatabaseSourceRecordRowDb,
|
|
324
338
|
options: { includeHeavyBuilderBodyValues?: boolean } = {},
|
|
325
339
|
): ContentDatabaseSourceRow {
|
|
@@ -393,6 +407,37 @@ function sourceSnapshotRowSelection(args: {
|
|
|
393
407
|
};
|
|
394
408
|
}
|
|
395
409
|
|
|
410
|
+
// Snapshot reads need document titles for change-set summaries. Body text is
|
|
411
|
+
// required only by write/review paths that explicitly request heavy Builder
|
|
412
|
+
// body values; routine list and source reads must not transfer it.
|
|
413
|
+
export function sourceSnapshotDocumentSelection(
|
|
414
|
+
includeHeavyBuilderBodyValues: true,
|
|
415
|
+
): {
|
|
416
|
+
id: typeof schema.documents.id;
|
|
417
|
+
title: typeof schema.documents.title;
|
|
418
|
+
content: typeof schema.documents.content;
|
|
419
|
+
};
|
|
420
|
+
export function sourceSnapshotDocumentSelection(
|
|
421
|
+
includeHeavyBuilderBodyValues: false,
|
|
422
|
+
): {
|
|
423
|
+
id: typeof schema.documents.id;
|
|
424
|
+
title: typeof schema.documents.title;
|
|
425
|
+
};
|
|
426
|
+
export function sourceSnapshotDocumentSelection(
|
|
427
|
+
includeHeavyBuilderBodyValues: boolean,
|
|
428
|
+
) {
|
|
429
|
+
return includeHeavyBuilderBodyValues
|
|
430
|
+
? {
|
|
431
|
+
id: schema.documents.id,
|
|
432
|
+
title: schema.documents.title,
|
|
433
|
+
content: schema.documents.content,
|
|
434
|
+
}
|
|
435
|
+
: {
|
|
436
|
+
id: schema.documents.id,
|
|
437
|
+
title: schema.documents.title,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
396
441
|
export function sourceValuesForSnapshot(
|
|
397
442
|
sourceValues: Record<string, DocumentPropertyValue>,
|
|
398
443
|
options: { includeHeavyBuilderBodyValues?: boolean } = {},
|
|
@@ -468,6 +513,47 @@ function serializeExecution(
|
|
|
468
513
|
};
|
|
469
514
|
}
|
|
470
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A blocked execution with neither an attempt token nor a response proves it
|
|
518
|
+
* stopped before the Builder dispatch claim. This includes a blocked dry run
|
|
519
|
+
* and a validated plan stopped by the final live preflight. Any other state or
|
|
520
|
+
* remote evidence stays frozen until normal reconciliation resolves it.
|
|
521
|
+
*/
|
|
522
|
+
export function builderExecutionIsProvablyLocallyBlockedUnsent(execution: {
|
|
523
|
+
state: string;
|
|
524
|
+
payloadJson: string;
|
|
525
|
+
attemptToken?: string | null;
|
|
526
|
+
}) {
|
|
527
|
+
const payload = parseObject<Record<string, unknown>>(execution.payloadJson);
|
|
528
|
+
return (
|
|
529
|
+
execution.state === "blocked" &&
|
|
530
|
+
!execution.attemptToken &&
|
|
531
|
+
!(payload?.response && typeof payload.response === "object")
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function canRefreshLocallyBlockedBuilderReview(
|
|
536
|
+
executions: Array<{
|
|
537
|
+
state: string;
|
|
538
|
+
payloadJson: string;
|
|
539
|
+
attemptToken?: string | null;
|
|
540
|
+
}>,
|
|
541
|
+
) {
|
|
542
|
+
return (
|
|
543
|
+
executions.length === 0 ||
|
|
544
|
+
executions.every(builderExecutionIsProvablyLocallyBlockedUnsent)
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export const CANCELLED_BUILDER_EXECUTION_SUMMARY =
|
|
549
|
+
"Cancelled before Builder dispatch.";
|
|
550
|
+
export const CANCELLED_BUILDER_REVIEW_NOTE_PREFIX =
|
|
551
|
+
"Cancelled prepared Builder update before dispatch";
|
|
552
|
+
|
|
553
|
+
export function builderReferenceIdSourceValueKey(sourceFieldKey: string) {
|
|
554
|
+
return `__agent_native_builder_reference_id:${sourceFieldKey}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
471
557
|
function riskRank(level: ContentDatabaseSourceRiskLevel) {
|
|
472
558
|
if (level === "high") return 3;
|
|
473
559
|
if (level === "medium") return 2;
|
|
@@ -609,7 +695,8 @@ const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
|
|
|
609
695
|
const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 25;
|
|
610
696
|
const BUILDER_BODY_HYDRATION_PROCESS_CONCURRENCY = 6;
|
|
611
697
|
const BUILDER_BODY_HYDRATION_MAX_ATTEMPTS = 5;
|
|
612
|
-
const BUILDER_BODY_HYDRATION_CODEC_VERSION =
|
|
698
|
+
const BUILDER_BODY_HYDRATION_CODEC_VERSION =
|
|
699
|
+
"readable-native-images-authoritative-raw-baseline-v9";
|
|
613
700
|
const BUILDER_CMS_REFRESH_INITIAL_PAGES = 1;
|
|
614
701
|
const BUILDER_BODY_NOT_AVAILABLE_ERROR = "body not yet available from Builder";
|
|
615
702
|
|
|
@@ -686,12 +773,27 @@ async function builderBodySnapshotForEntry(entry: BuilderCmsSourceEntry) {
|
|
|
686
773
|
return {
|
|
687
774
|
content: readableBundle.mdx.body,
|
|
688
775
|
losslessContent: losslessBundle.mdx.body,
|
|
689
|
-
blocksHash:
|
|
776
|
+
blocksHash: builderAuthoritativeRawBodyHash({
|
|
777
|
+
entry,
|
|
778
|
+
generatedBlocks: losslessBundle.blocks,
|
|
779
|
+
}),
|
|
690
780
|
readableMapJson: null,
|
|
691
781
|
sidecarsJson: JSON.stringify(sidecars),
|
|
692
782
|
};
|
|
693
783
|
}
|
|
694
784
|
|
|
785
|
+
export function builderAuthoritativeRawBodyHash(args: {
|
|
786
|
+
entry: BuilderCmsSourceEntry;
|
|
787
|
+
generatedBlocks: unknown[];
|
|
788
|
+
}) {
|
|
789
|
+
const rawBlocks = args.entry.rawEntry
|
|
790
|
+
? builderEntryBlocks(args.entry.rawEntry)
|
|
791
|
+
: [];
|
|
792
|
+
return builderBlocksHash(
|
|
793
|
+
rawBlocks.length > 0 ? rawBlocks : args.generatedBlocks,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
695
797
|
export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
|
|
696
798
|
const hash = stringSourceValue(
|
|
697
799
|
entry.sourceValues,
|
|
@@ -700,6 +802,15 @@ export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
|
|
|
700
802
|
return `${hash ?? entry.updatedAt ?? entry.id}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
|
|
701
803
|
}
|
|
702
804
|
|
|
805
|
+
export function builderBodyUnavailableVersion(entry: BuilderCmsSourceEntry) {
|
|
806
|
+
const lastUpdated =
|
|
807
|
+
stringSourceValue(entry.sourceValues, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
|
|
808
|
+
stringSourceValue(entry.sourceValues, "lastUpdated") ??
|
|
809
|
+
entry.updatedAt ??
|
|
810
|
+
entry.id;
|
|
811
|
+
return `${lastUpdated}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
703
814
|
function normalizeHydrationLimit(limit: number | null | undefined) {
|
|
704
815
|
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
|
705
816
|
return BUILDER_BODY_HYDRATION_BATCH_LIMIT;
|
|
@@ -800,9 +911,14 @@ function builderStoredBodyIsStale(args: {
|
|
|
800
911
|
>;
|
|
801
912
|
entry: BuilderCmsSourceEntry;
|
|
802
913
|
}) {
|
|
914
|
+
const expectedVersion =
|
|
915
|
+
args.item.bodyHydrationStatus === "unavailable"
|
|
916
|
+
? builderBodyUnavailableVersion(args.entry)
|
|
917
|
+
: builderBodyHydrationVersion(args.entry);
|
|
803
918
|
return (
|
|
804
|
-
args.item.bodyHydrationStatus !== "hydrated"
|
|
805
|
-
|
|
919
|
+
(args.item.bodyHydrationStatus !== "hydrated" &&
|
|
920
|
+
args.item.bodyHydrationStatus !== "unavailable") ||
|
|
921
|
+
args.item.bodyHydrationVersion !== expectedVersion
|
|
806
922
|
);
|
|
807
923
|
}
|
|
808
924
|
|
|
@@ -813,7 +929,7 @@ function builderEntryHasBodyContent(entry: BuilderCmsSourceEntry | null) {
|
|
|
813
929
|
)?.trim();
|
|
814
930
|
}
|
|
815
931
|
|
|
816
|
-
async function
|
|
932
|
+
async function builderBodySnapshotFromStoredLossless(args: {
|
|
817
933
|
losslessContent: string | null;
|
|
818
934
|
sidecarsJson: string | null;
|
|
819
935
|
}) {
|
|
@@ -825,12 +941,60 @@ async function readableBuilderBodyFromStoredLossless(args: {
|
|
|
825
941
|
sidecars,
|
|
826
942
|
);
|
|
827
943
|
if (blocks.length === 0) return null;
|
|
828
|
-
return
|
|
944
|
+
return {
|
|
945
|
+
content: await builderBlocksToReadableMarkdown(blocks),
|
|
946
|
+
blocksHash: builderBlocksHash(blocks),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export function builderBodyHydrationNeedsLiveBaseline(args: {
|
|
951
|
+
bodyHydrationVersion: string | null | undefined;
|
|
952
|
+
storedBlocksHash: string | null | undefined;
|
|
953
|
+
rebuiltBlocksHash: string | null | undefined;
|
|
954
|
+
}) {
|
|
955
|
+
if (!args.storedBlocksHash) return false;
|
|
956
|
+
if (
|
|
957
|
+
args.bodyHydrationVersion &&
|
|
958
|
+
!args.bodyHydrationVersion.endsWith(
|
|
959
|
+
`:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`,
|
|
960
|
+
)
|
|
961
|
+
) {
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
return Boolean(
|
|
965
|
+
!args.rebuiltBlocksHash || args.storedBlocksHash !== args.rebuiltBlocksHash,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export function builderBodyHydrationIsCodecMigration(
|
|
970
|
+
bodyHydrationVersion: string | null | undefined,
|
|
971
|
+
) {
|
|
972
|
+
return Boolean(
|
|
973
|
+
bodyHydrationVersion &&
|
|
974
|
+
!bodyHydrationVersion.endsWith(`:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`),
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
export function builderBodyHydrationCanAdoptSameVersionVariant(args: {
|
|
979
|
+
documentContent: string | null | undefined;
|
|
980
|
+
persistedContent: string | null | undefined;
|
|
981
|
+
}) {
|
|
982
|
+
return (
|
|
983
|
+
args.documentContent !== undefined &&
|
|
984
|
+
normalizeBuilderBodyBaselineContent(args.documentContent) ===
|
|
985
|
+
normalizeBuilderBodyBaselineContent(args.persistedContent)
|
|
986
|
+
);
|
|
829
987
|
}
|
|
830
988
|
|
|
831
|
-
async function refreshBuilderBodySourceValuesFromStoredLossless(
|
|
989
|
+
export async function refreshBuilderBodySourceValuesFromStoredLossless(
|
|
832
990
|
entry: BuilderCmsSourceEntry,
|
|
833
991
|
) {
|
|
992
|
+
if (entry.rawEntry && builderEntryBlocks(entry.rawEntry).length > 0) {
|
|
993
|
+
// A fresh single-entry Builder read is the authoritative preflight
|
|
994
|
+
// representation. Never replace its raw block hash with a hash rebuilt
|
|
995
|
+
// from generated lossless MDX.
|
|
996
|
+
return entry;
|
|
997
|
+
}
|
|
834
998
|
const losslessContent = stringSourceValue(
|
|
835
999
|
entry.sourceValues,
|
|
836
1000
|
BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
|
|
@@ -839,20 +1003,51 @@ async function refreshBuilderBodySourceValuesFromStoredLossless(
|
|
|
839
1003
|
entry.sourceValues,
|
|
840
1004
|
BUILDER_CMS_BODY_SIDECARS_KEY,
|
|
841
1005
|
);
|
|
842
|
-
const
|
|
1006
|
+
const snapshot = await builderBodySnapshotFromStoredLossless({
|
|
843
1007
|
losslessContent,
|
|
844
1008
|
sidecarsJson,
|
|
845
1009
|
});
|
|
846
|
-
if (!
|
|
1010
|
+
if (!snapshot) return entry;
|
|
847
1011
|
return {
|
|
848
1012
|
...entry,
|
|
849
1013
|
sourceValues: {
|
|
850
1014
|
...entry.sourceValues,
|
|
851
|
-
[BUILDER_CMS_BODY_CONTENT_KEY]: content,
|
|
1015
|
+
[BUILDER_CMS_BODY_CONTENT_KEY]: snapshot.content,
|
|
1016
|
+
[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
|
|
852
1017
|
},
|
|
853
1018
|
};
|
|
854
1019
|
}
|
|
855
1020
|
|
|
1021
|
+
function builderTimestampEpoch(value: string | null | undefined) {
|
|
1022
|
+
if (!value?.trim()) return null;
|
|
1023
|
+
const numeric = Number(value);
|
|
1024
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
1025
|
+
const parsed = Date.parse(value);
|
|
1026
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
export function builderBodyBaselineHasSameVersionConflict(args: {
|
|
1030
|
+
persistedBlocksHash: string | null | undefined;
|
|
1031
|
+
incomingBlocksHash: string | null | undefined;
|
|
1032
|
+
persistedLastUpdated: string | null | undefined;
|
|
1033
|
+
incomingLastUpdated: string | null | undefined;
|
|
1034
|
+
}) {
|
|
1035
|
+
if (
|
|
1036
|
+
!args.persistedBlocksHash ||
|
|
1037
|
+
!args.incomingBlocksHash ||
|
|
1038
|
+
args.persistedBlocksHash === args.incomingBlocksHash
|
|
1039
|
+
) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
const persistedEpoch = builderTimestampEpoch(args.persistedLastUpdated);
|
|
1043
|
+
const incomingEpoch = builderTimestampEpoch(args.incomingLastUpdated);
|
|
1044
|
+
return (
|
|
1045
|
+
persistedEpoch !== null &&
|
|
1046
|
+
incomingEpoch !== null &&
|
|
1047
|
+
persistedEpoch === incomingEpoch
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
856
1051
|
function builderEntryFromSourceRow(args: {
|
|
857
1052
|
row: Pick<
|
|
858
1053
|
ContentDatabaseSourceRecordRowDb,
|
|
@@ -911,8 +1106,12 @@ async function readBuilderEntryWithLiveBodyFromSourceRow(args: {
|
|
|
911
1106
|
...liveEntry.sourceValues,
|
|
912
1107
|
},
|
|
913
1108
|
};
|
|
914
|
-
|
|
915
|
-
|
|
1109
|
+
// This entry came from a fresh Builder response. Preserve the block hash
|
|
1110
|
+
// computed from those authoritative raw blocks; rebuilding the generated
|
|
1111
|
+
// lossless MDX can normalize block details and produce a different hash than
|
|
1112
|
+
// the execute-time live preflight sees for the same response.
|
|
1113
|
+
const refreshedEntry = await withBuilderBodySourceValues(
|
|
1114
|
+
entryWithStoredValues,
|
|
916
1115
|
);
|
|
917
1116
|
return builderEntryHasBodyContent(refreshedEntry) ? refreshedEntry : null;
|
|
918
1117
|
}
|
|
@@ -1068,15 +1267,84 @@ export async function enqueueBuilderBodyHydrationForItems(args: {
|
|
|
1068
1267
|
now: string;
|
|
1069
1268
|
}) {
|
|
1070
1269
|
if (!args.builderEntriesByDocumentId?.size) return;
|
|
1270
|
+
const persistedStateByDocumentId = new Map<
|
|
1271
|
+
string,
|
|
1272
|
+
{
|
|
1273
|
+
entry: BuilderCmsSourceEntry;
|
|
1274
|
+
bodyHydrationStatus: string | null;
|
|
1275
|
+
bodyHydrationVersion: string | null;
|
|
1276
|
+
documentContent: string | null;
|
|
1277
|
+
}
|
|
1278
|
+
>();
|
|
1279
|
+
const documentIds = args.items.map((item) => item.document.id);
|
|
1280
|
+
for (const documentIdChunk of chunks(documentIds, idChunkSize())) {
|
|
1281
|
+
const rows = await getDb()
|
|
1282
|
+
.select({
|
|
1283
|
+
documentId: schema.contentDatabaseSourceRows.documentId,
|
|
1284
|
+
sourceRowId: schema.contentDatabaseSourceRows.sourceRowId,
|
|
1285
|
+
sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
|
|
1286
|
+
lastSourceUpdatedAt:
|
|
1287
|
+
schema.contentDatabaseSourceRows.lastSourceUpdatedAt,
|
|
1288
|
+
bodyHydrationStatus: schema.contentDatabaseItems.bodyHydrationStatus,
|
|
1289
|
+
bodyHydrationVersion: schema.contentDatabaseItems.bodyHydrationVersion,
|
|
1290
|
+
documentContent: schema.documents.content,
|
|
1291
|
+
})
|
|
1292
|
+
.from(schema.contentDatabaseSourceRows)
|
|
1293
|
+
.innerJoin(
|
|
1294
|
+
schema.contentDatabaseItems,
|
|
1295
|
+
eq(
|
|
1296
|
+
schema.contentDatabaseItems.id,
|
|
1297
|
+
schema.contentDatabaseSourceRows.databaseItemId,
|
|
1298
|
+
),
|
|
1299
|
+
)
|
|
1300
|
+
.innerJoin(
|
|
1301
|
+
schema.documents,
|
|
1302
|
+
eq(schema.documents.id, schema.contentDatabaseSourceRows.documentId),
|
|
1303
|
+
)
|
|
1304
|
+
.where(
|
|
1305
|
+
and(
|
|
1306
|
+
eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId),
|
|
1307
|
+
inArray(schema.contentDatabaseSourceRows.documentId, documentIdChunk),
|
|
1308
|
+
),
|
|
1309
|
+
);
|
|
1310
|
+
for (const row of rows) {
|
|
1311
|
+
const entry = builderEntryFromSourceRow({
|
|
1312
|
+
row,
|
|
1313
|
+
sourceTable: args.sourceTable,
|
|
1314
|
+
fallbackTitle: row.sourceRowId ?? row.documentId,
|
|
1315
|
+
});
|
|
1316
|
+
if (entry) {
|
|
1317
|
+
persistedStateByDocumentId.set(row.documentId, {
|
|
1318
|
+
entry,
|
|
1319
|
+
bodyHydrationStatus: row.bodyHydrationStatus,
|
|
1320
|
+
bodyHydrationVersion: row.bodyHydrationVersion,
|
|
1321
|
+
documentContent: row.documentContent,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1071
1326
|
const requests: BuilderBodyHydrationEnqueueRequest[] = [];
|
|
1072
1327
|
for (const item of args.items) {
|
|
1073
1328
|
const entry = args.builderEntriesByDocumentId.get(item.document.id);
|
|
1074
1329
|
if (!entry) continue;
|
|
1330
|
+
const persistedState = persistedStateByDocumentId.get(item.document.id);
|
|
1331
|
+
const persistedEntry = persistedState?.entry ?? entry;
|
|
1332
|
+
const bodyHydrationStatus =
|
|
1333
|
+
persistedState?.bodyHydrationStatus ?? item.bodyHydration?.status;
|
|
1334
|
+
const bodyHydrationVersion =
|
|
1335
|
+
persistedState?.bodyHydrationVersion ?? item.bodyHydration?.version;
|
|
1336
|
+
const documentContent =
|
|
1337
|
+
persistedState?.documentContent ?? item.document.content;
|
|
1338
|
+
const expectedVersion =
|
|
1339
|
+
bodyHydrationStatus === "unavailable"
|
|
1340
|
+
? builderBodyUnavailableVersion(persistedEntry)
|
|
1341
|
+
: builderBodyHydrationVersion(persistedEntry);
|
|
1075
1342
|
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1343
|
+
(bodyHydrationStatus === "unavailable" ||
|
|
1344
|
+
(bodyHydrationStatus === "hydrated" &&
|
|
1345
|
+
!isEffectivelyEmptyDocumentContent(documentContent) &&
|
|
1346
|
+
!builderBodyIsRawPlaceholderOnly(documentContent))) &&
|
|
1347
|
+
bodyHydrationVersion === expectedVersion
|
|
1080
1348
|
) {
|
|
1081
1349
|
continue;
|
|
1082
1350
|
}
|
|
@@ -1197,11 +1465,16 @@ async function processBuilderBodyHydrationJob(
|
|
|
1197
1465
|
preloaded?: {
|
|
1198
1466
|
sourceRow?: ContentDatabaseSourceRecordRowDb | null;
|
|
1199
1467
|
documentContent?: string | null;
|
|
1468
|
+
bodyHydrationVersion?: string | null;
|
|
1200
1469
|
},
|
|
1201
1470
|
) {
|
|
1202
1471
|
const db = getDb();
|
|
1203
1472
|
const entry = parseHydrationEntry(row);
|
|
1204
1473
|
if (!entry) throw new Error("Builder body hydration entry is missing.");
|
|
1474
|
+
const queuedBlocksHash = stringSourceValue(
|
|
1475
|
+
entry.sourceValues,
|
|
1476
|
+
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
1477
|
+
);
|
|
1205
1478
|
let activeSourceEntryJson = row.sourceEntryJson;
|
|
1206
1479
|
let entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
|
|
1207
1480
|
await withBuilderBodySourceValues(entry),
|
|
@@ -1227,6 +1500,73 @@ async function processBuilderBodyHydrationJob(
|
|
|
1227
1500
|
parseObject<Record<string, DocumentPropertyValue>>(
|
|
1228
1501
|
sourceRow?.sourceValuesJson ?? "{}",
|
|
1229
1502
|
) ?? {};
|
|
1503
|
+
const rebuiltBlocksHash = stringSourceValue(
|
|
1504
|
+
entryWithBody.sourceValues,
|
|
1505
|
+
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
1506
|
+
);
|
|
1507
|
+
const storedBlocksHash =
|
|
1508
|
+
queuedBlocksHash ??
|
|
1509
|
+
stringSourceValue(sourceValues, BUILDER_CMS_BODY_BLOCKS_HASH_KEY);
|
|
1510
|
+
const isCodecMigration = builderBodyHydrationIsCodecMigration(
|
|
1511
|
+
preloaded?.bodyHydrationVersion,
|
|
1512
|
+
);
|
|
1513
|
+
if (
|
|
1514
|
+
builderBodyHydrationNeedsLiveBaseline({
|
|
1515
|
+
bodyHydrationVersion: preloaded?.bodyHydrationVersion,
|
|
1516
|
+
storedBlocksHash,
|
|
1517
|
+
rebuiltBlocksHash,
|
|
1518
|
+
})
|
|
1519
|
+
) {
|
|
1520
|
+
if (!sourceRow) {
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
"Builder body baseline migration requires a linked source row.",
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
const liveEntry = await readBuilderEntryWithLiveBodyFromSourceRow({
|
|
1526
|
+
row: sourceRow,
|
|
1527
|
+
sourceTable: row.sourceTable,
|
|
1528
|
+
fallbackTitle: entry.title,
|
|
1529
|
+
});
|
|
1530
|
+
if (!liveEntry) {
|
|
1531
|
+
throw new Error(
|
|
1532
|
+
"Builder body baseline migration could not read a fresh remote body; retry the refresh before reviewing or publishing.",
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
entryWithBody = liveEntry;
|
|
1536
|
+
}
|
|
1537
|
+
const incomingBlocksHash = stringSourceValue(
|
|
1538
|
+
entryWithBody.sourceValues,
|
|
1539
|
+
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
1540
|
+
);
|
|
1541
|
+
const persistedBaselineLastUpdated =
|
|
1542
|
+
stringSourceValue(sourceValues, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
|
|
1543
|
+
stringSourceValue(sourceValues, "lastUpdated");
|
|
1544
|
+
const incomingLastUpdated =
|
|
1545
|
+
stringSourceValue(entryWithBody.sourceValues, "lastUpdated") ??
|
|
1546
|
+
entryWithBody.updatedAt;
|
|
1547
|
+
if (
|
|
1548
|
+
!isCodecMigration &&
|
|
1549
|
+
builderBodyBaselineHasSameVersionConflict({
|
|
1550
|
+
persistedBlocksHash: stringSourceValue(
|
|
1551
|
+
sourceValues,
|
|
1552
|
+
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
1553
|
+
),
|
|
1554
|
+
incomingBlocksHash,
|
|
1555
|
+
persistedLastUpdated: persistedBaselineLastUpdated,
|
|
1556
|
+
incomingLastUpdated,
|
|
1557
|
+
}) &&
|
|
1558
|
+
!builderBodyHydrationCanAdoptSameVersionVariant({
|
|
1559
|
+
documentContent: preloaded?.documentContent,
|
|
1560
|
+
persistedContent: stringSourceValue(
|
|
1561
|
+
sourceValues,
|
|
1562
|
+
BUILDER_CMS_BODY_CONTENT_KEY,
|
|
1563
|
+
),
|
|
1564
|
+
})
|
|
1565
|
+
) {
|
|
1566
|
+
throw new Error(
|
|
1567
|
+
"Builder returned inconsistent body variants for the same lastUpdated value; the stored baseline and local document were preserved for retry.",
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1230
1570
|
let nextValues = {
|
|
1231
1571
|
...sourceValues,
|
|
1232
1572
|
...entryWithBody.sourceValues,
|
|
@@ -1355,9 +1695,11 @@ async function processBuilderBodyHydrationJob(
|
|
|
1355
1695
|
await tx
|
|
1356
1696
|
.update(schema.contentDatabaseItems)
|
|
1357
1697
|
.set({
|
|
1358
|
-
bodyHydrationStatus: "
|
|
1698
|
+
bodyHydrationStatus: "unavailable",
|
|
1359
1699
|
bodyHydrationAttemptedAt: now,
|
|
1360
|
-
bodyHydrationError:
|
|
1700
|
+
bodyHydrationError: null,
|
|
1701
|
+
bodyHydrationVersion:
|
|
1702
|
+
builderBodyUnavailableVersion(entryWithBody),
|
|
1361
1703
|
updatedAt: now,
|
|
1362
1704
|
})
|
|
1363
1705
|
.where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
|
|
@@ -1570,14 +1912,11 @@ async function processBuilderBodyHydrationJob(
|
|
|
1570
1912
|
})
|
|
1571
1913
|
.where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
|
|
1572
1914
|
});
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
// Non-fatal: the updated document row still lets open editors reconcile.
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1915
|
+
// Keep persisted and in-memory Yjs state intact. The SQL content + updatedAt
|
|
1916
|
+
// written above are authoritative; an open full-page editor reconciles that
|
|
1917
|
+
// snapshot into its Y.Doc. Deleting collab state here can race a connected
|
|
1918
|
+
// client, briefly bootstrap an empty fragment, and let that stale client
|
|
1919
|
+
// repersist the empty state over the newly hydrated Builder body.
|
|
1581
1920
|
}
|
|
1582
1921
|
|
|
1583
1922
|
async function enqueueStaleBuilderBodyHydrationForOpenDocument(args: {
|
|
@@ -1796,6 +2135,29 @@ export async function processBuilderBodyHydrationQueue(args: {
|
|
|
1796
2135
|
const sourceRowsByItemId = new Map(
|
|
1797
2136
|
sourceRows.map((row) => [row.databaseItemId, row]),
|
|
1798
2137
|
);
|
|
2138
|
+
const itemVersions =
|
|
2139
|
+
claimedJobs.length > 0
|
|
2140
|
+
? (
|
|
2141
|
+
await Promise.all(
|
|
2142
|
+
chunks(
|
|
2143
|
+
claimedJobs.map((job) => job.databaseItemId),
|
|
2144
|
+
idChunkSize(),
|
|
2145
|
+
).map((idChunk) =>
|
|
2146
|
+
db
|
|
2147
|
+
.select({
|
|
2148
|
+
id: schema.contentDatabaseItems.id,
|
|
2149
|
+
bodyHydrationVersion:
|
|
2150
|
+
schema.contentDatabaseItems.bodyHydrationVersion,
|
|
2151
|
+
})
|
|
2152
|
+
.from(schema.contentDatabaseItems)
|
|
2153
|
+
.where(inArray(schema.contentDatabaseItems.id, idChunk)),
|
|
2154
|
+
),
|
|
2155
|
+
)
|
|
2156
|
+
).flat()
|
|
2157
|
+
: [];
|
|
2158
|
+
const bodyHydrationVersionByItemId = new Map(
|
|
2159
|
+
itemVersions.map((item) => [item.id, item.bodyHydrationVersion]),
|
|
2160
|
+
);
|
|
1799
2161
|
const documents =
|
|
1800
2162
|
claimedJobs.length > 0
|
|
1801
2163
|
? (
|
|
@@ -1828,6 +2190,8 @@ export async function processBuilderBodyHydrationQueue(args: {
|
|
|
1828
2190
|
if (delayMs > 0) await sleep(delayMs);
|
|
1829
2191
|
await processBuilderBodyHydrationJob(job, attemptNow, {
|
|
1830
2192
|
sourceRow: sourceRowsByItemId.get(job.databaseItemId) ?? null,
|
|
2193
|
+
bodyHydrationVersion:
|
|
2194
|
+
bodyHydrationVersionByItemId.get(job.databaseItemId) ?? null,
|
|
1831
2195
|
documentContent: documentContentById.has(job.documentId)
|
|
1832
2196
|
? (documentContentById.get(job.documentId) ?? null)
|
|
1833
2197
|
: undefined,
|
|
@@ -1935,6 +2299,8 @@ export async function withBuilderBodySourceValues(
|
|
|
1935
2299
|
[BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: snapshot.losslessContent,
|
|
1936
2300
|
[BUILDER_CMS_BODY_READABLE_MAP_KEY]: snapshot.readableMapJson,
|
|
1937
2301
|
[BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
|
|
2302
|
+
[BUILDER_CMS_BODY_LAST_UPDATED_KEY]:
|
|
2303
|
+
stringSourceValue(entry.sourceValues, "lastUpdated") ?? entry.updatedAt,
|
|
1938
2304
|
[BUILDER_CMS_BODY_SIDECARS_KEY]: snapshot.sidecarsJson,
|
|
1939
2305
|
},
|
|
1940
2306
|
};
|
|
@@ -1973,9 +2339,17 @@ export async function builderBodyChangeForLocalContent(args: {
|
|
|
1973
2339
|
const localContent = args.localContent ?? "";
|
|
1974
2340
|
if (!currentHash && !currentContent && !localContent.trim()) return null;
|
|
1975
2341
|
if (!currentContent?.trim() && !losslessContent?.trim()) return null;
|
|
2342
|
+
// Native media is converter-owned output. Re-run it even when the editable
|
|
2343
|
+
// Markdown text is byte-for-byte unchanged: a converter upgrade can turn a
|
|
2344
|
+
// legacy Text block containing a Markdown image into a real Builder Image
|
|
2345
|
+
// (or emit a native Video) without changing the document text at all.
|
|
2346
|
+
const usesCurrentMediaConverter =
|
|
2347
|
+
/!\[[^\]]*\]\(\s*https?:\/\//i.test(localContent) ||
|
|
2348
|
+
/<(?:img|video)\b/i.test(localContent);
|
|
1976
2349
|
const normalizedLocalContent =
|
|
1977
2350
|
normalizeBuilderBodyBaselineContent(localContent);
|
|
1978
2351
|
if (
|
|
2352
|
+
!usesCurrentMediaConverter &&
|
|
1979
2353
|
normalizedLocalContent &&
|
|
1980
2354
|
normalizedLocalContent ===
|
|
1981
2355
|
normalizeBuilderBodyBaselineContent(currentContent)
|
|
@@ -1983,6 +2357,7 @@ export async function builderBodyChangeForLocalContent(args: {
|
|
|
1983
2357
|
return null;
|
|
1984
2358
|
}
|
|
1985
2359
|
if (
|
|
2360
|
+
!usesCurrentMediaConverter &&
|
|
1986
2361
|
normalizedLocalContent &&
|
|
1987
2362
|
normalizedLocalContent ===
|
|
1988
2363
|
normalizeBuilderBodyBaselineContent(losslessContent)
|
|
@@ -2005,8 +2380,15 @@ export async function builderBodyChangeForLocalContent(args: {
|
|
|
2005
2380
|
}
|
|
2006
2381
|
|
|
2007
2382
|
try {
|
|
2008
|
-
const proposed =
|
|
2009
|
-
|
|
2383
|
+
const proposed = usesCurrentMediaConverter
|
|
2384
|
+
? {
|
|
2385
|
+
blocks: await builderMdxBodyToBuilderBlocks(
|
|
2386
|
+
normalizeUnsourcedBuilderCreateMdx(localContent),
|
|
2387
|
+
sidecars,
|
|
2388
|
+
),
|
|
2389
|
+
warnings: [] as string[],
|
|
2390
|
+
}
|
|
2391
|
+
: losslessContent && !localContent.includes("<Builder")
|
|
2010
2392
|
? await builderReadableBodyToBuilderBlocks({
|
|
2011
2393
|
localContent,
|
|
2012
2394
|
losslessContent,
|
|
@@ -2062,6 +2444,98 @@ export async function builderBodyChangeForLocalContent(args: {
|
|
|
2062
2444
|
}
|
|
2063
2445
|
}
|
|
2064
2446
|
|
|
2447
|
+
function htmlMediaAttribute(tag: string, name: string) {
|
|
2448
|
+
const match = tag.match(
|
|
2449
|
+
new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
|
|
2450
|
+
);
|
|
2451
|
+
return match?.[1] ?? match?.[2] ?? null;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function normalizeUnsourcedBuilderCreateMdx(content: string) {
|
|
2455
|
+
return content.replace(/<img\b[^>]*\/?\s*>/gi, (tag) => {
|
|
2456
|
+
const src = htmlMediaAttribute(tag, "src");
|
|
2457
|
+
if (!src) return tag;
|
|
2458
|
+
const alt = htmlMediaAttribute(tag, "alt") ?? "";
|
|
2459
|
+
return `![${alt.replace(/]/g, "\\]")}](${src.replace(/\)/g, "%29")})`;
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
export async function builderBodyChangeForUnsourcedLocalCreate(args: {
|
|
2464
|
+
localContent: string | null | undefined;
|
|
2465
|
+
}): Promise<ContentDatabaseSourceBodyChange | null> {
|
|
2466
|
+
const localContent = args.localContent ?? "";
|
|
2467
|
+
if (!localContent.trim()) return null;
|
|
2468
|
+
const sidecarsJson = "{}";
|
|
2469
|
+
|
|
2470
|
+
try {
|
|
2471
|
+
const blocks = await builderMdxBodyToBuilderBlocks(
|
|
2472
|
+
normalizeUnsourcedBuilderCreateMdx(localContent),
|
|
2473
|
+
{},
|
|
2474
|
+
);
|
|
2475
|
+
return {
|
|
2476
|
+
summary: "Builder draft body ready to create.",
|
|
2477
|
+
currentExcerpt: null,
|
|
2478
|
+
proposedExcerpt: bodyExcerpt(localContent),
|
|
2479
|
+
currentHash: null,
|
|
2480
|
+
proposedHash: builderBlocksHash(blocks),
|
|
2481
|
+
proposedContent: localContent,
|
|
2482
|
+
proposedBlocksJson: JSON.stringify(blocks),
|
|
2483
|
+
sidecarsJson,
|
|
2484
|
+
warnings: [],
|
|
2485
|
+
};
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
const message =
|
|
2488
|
+
error instanceof Error
|
|
2489
|
+
? error.message
|
|
2490
|
+
: "Builder body could not be converted.";
|
|
2491
|
+
return {
|
|
2492
|
+
summary: "Builder draft body needs attention before create.",
|
|
2493
|
+
currentExcerpt: null,
|
|
2494
|
+
proposedExcerpt: bodyExcerpt(localContent),
|
|
2495
|
+
currentHash: null,
|
|
2496
|
+
proposedHash: null,
|
|
2497
|
+
proposedContent: localContent,
|
|
2498
|
+
proposedBlocksJson: null,
|
|
2499
|
+
sidecarsJson,
|
|
2500
|
+
warnings: [message],
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
export async function builderBodyChangeForSourceSnapshotDocument(args: {
|
|
2506
|
+
row?: {
|
|
2507
|
+
documentId: string;
|
|
2508
|
+
sourceRowId: string;
|
|
2509
|
+
sourceQualifiedId: string;
|
|
2510
|
+
provenance: string | null;
|
|
2511
|
+
sourceValuesJson: string;
|
|
2512
|
+
};
|
|
2513
|
+
isHydrated: boolean;
|
|
2514
|
+
allowUnsourcedCreate: boolean;
|
|
2515
|
+
localContent: string | null | undefined;
|
|
2516
|
+
}): Promise<ContentDatabaseSourceBodyChange | null> {
|
|
2517
|
+
if (args.row) {
|
|
2518
|
+
const identity = builderCmsSourceRowIdentityState({ row: args.row });
|
|
2519
|
+
if (identity.isSyntheticFixture) {
|
|
2520
|
+
// Fixture rows are local placeholders, not imported Builder baselines.
|
|
2521
|
+
// Execution resolves their synthetic identity to create_draft, so their
|
|
2522
|
+
// local body must follow the same create path even before hydration.
|
|
2523
|
+
return builderBodyChangeForUnsourcedLocalCreate({
|
|
2524
|
+
localContent: args.localContent,
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
if (!args.isHydrated) return null;
|
|
2528
|
+
return builderBodyChangeForLocalContent({
|
|
2529
|
+
row: args.row,
|
|
2530
|
+
localContent: args.localContent,
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
if (!args.allowUnsourcedCreate) return null;
|
|
2534
|
+
return builderBodyChangeForUnsourcedLocalCreate({
|
|
2535
|
+
localContent: args.localContent,
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2065
2539
|
export function buildBuilderLocalOutboundChangeSets(args: {
|
|
2066
2540
|
source: ContentDatabaseSourceRowDb;
|
|
2067
2541
|
rowRows: ContentDatabaseSourceRecordRowDb[];
|
|
@@ -2078,6 +2552,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2078
2552
|
sourceFieldLabel: string;
|
|
2079
2553
|
propertyType?: DocumentProperty["definition"]["type"] | null;
|
|
2080
2554
|
propertyOptions?: DocumentPropertyOptions;
|
|
2555
|
+
sourceFieldType?: string;
|
|
2556
|
+
sourceFieldModel?: string;
|
|
2081
2557
|
}>;
|
|
2082
2558
|
// Row-union scoping (multi-source). Documents owned by ANOTHER source must
|
|
2083
2559
|
// never be create candidates for this one — each row belongs to exactly one
|
|
@@ -2093,6 +2569,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2093
2569
|
taggedSourceByDocumentId?: Map<string, string>;
|
|
2094
2570
|
bodyChangeByDocumentId?: Map<string, ContentDatabaseSourceBodyChange>;
|
|
2095
2571
|
sourceImportedDocumentIds?: Set<string>;
|
|
2572
|
+
/**
|
|
2573
|
+
* Rejected change-sets that are provably cancellations of a prepared,
|
|
2574
|
+
* pre-dispatch Builder gate. Their exact snapshot remains durable audit
|
|
2575
|
+
* history and suppresses only the byte-equivalent local-vs-source diff.
|
|
2576
|
+
*/
|
|
2577
|
+
cancelledRejectedChangeSetIds?: Set<string>;
|
|
2096
2578
|
}): ContentDatabaseSourceChangeSet[] {
|
|
2097
2579
|
if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return [];
|
|
2098
2580
|
|
|
@@ -2103,6 +2585,106 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2103
2585
|
normalizeCapabilities(args.source.capabilitiesJson).liveWritesEnabled ===
|
|
2104
2586
|
true;
|
|
2105
2587
|
const pending: ContentDatabaseSourceChangeSet[] = [];
|
|
2588
|
+
|
|
2589
|
+
const sameCancelledSnapshot = (
|
|
2590
|
+
candidate: Pick<
|
|
2591
|
+
ContentDatabaseSourceChangeSet,
|
|
2592
|
+
| "databaseItemId"
|
|
2593
|
+
| "documentId"
|
|
2594
|
+
| "kind"
|
|
2595
|
+
| "direction"
|
|
2596
|
+
| "pushMode"
|
|
2597
|
+
| "fieldChanges"
|
|
2598
|
+
| "bodyChange"
|
|
2599
|
+
>,
|
|
2600
|
+
) =>
|
|
2601
|
+
args.storedChangeSets.some(
|
|
2602
|
+
(stored) =>
|
|
2603
|
+
stored.state === "rejected" &&
|
|
2604
|
+
args.cancelledRejectedChangeSetIds?.has(stored.id) === true &&
|
|
2605
|
+
stored.databaseItemId === candidate.databaseItemId &&
|
|
2606
|
+
stored.documentId === candidate.documentId &&
|
|
2607
|
+
stored.kind === candidate.kind &&
|
|
2608
|
+
stored.direction === candidate.direction &&
|
|
2609
|
+
stored.pushMode === candidate.pushMode &&
|
|
2610
|
+
stableValueString(stored.fieldChanges) ===
|
|
2611
|
+
stableValueString(candidate.fieldChanges) &&
|
|
2612
|
+
stableValueString(stored.bodyChange) ===
|
|
2613
|
+
stableValueString(candidate.bodyChange),
|
|
2614
|
+
);
|
|
2615
|
+
|
|
2616
|
+
const reviewableBuilderValue = (
|
|
2617
|
+
field: NonNullable<typeof args.writableFields>[number],
|
|
2618
|
+
localValue: unknown,
|
|
2619
|
+
): Pick<
|
|
2620
|
+
ContentDatabaseSourceFieldChange,
|
|
2621
|
+
"proposedValue" | "builderValueJson"
|
|
2622
|
+
> => {
|
|
2623
|
+
const type = field.propertyType;
|
|
2624
|
+
const options = field.propertyOptions?.options ?? [];
|
|
2625
|
+
const optionNameById = new Map(
|
|
2626
|
+
options.map((option) => [option.id, option.name]),
|
|
2627
|
+
);
|
|
2628
|
+
const sourceType = field.sourceFieldType?.trim().toLowerCase();
|
|
2629
|
+
if (sourceType === "reference") {
|
|
2630
|
+
const id = typeof localValue === "string" ? localValue.trim() : "";
|
|
2631
|
+
const label = optionNameById.get(id) ?? id;
|
|
2632
|
+
const builderValue =
|
|
2633
|
+
id && field.sourceFieldModel
|
|
2634
|
+
? {
|
|
2635
|
+
"@type": "@builder.io/core:Reference",
|
|
2636
|
+
id,
|
|
2637
|
+
model: field.sourceFieldModel,
|
|
2638
|
+
}
|
|
2639
|
+
: null;
|
|
2640
|
+
return {
|
|
2641
|
+
proposedValue: label || null,
|
|
2642
|
+
builderValueJson: JSON.stringify(builderValue),
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
if (sourceType === "file") {
|
|
2646
|
+
const values = Array.isArray(localValue)
|
|
2647
|
+
? localValue.filter(
|
|
2648
|
+
(value): value is string => typeof value === "string",
|
|
2649
|
+
)
|
|
2650
|
+
: typeof localValue === "string"
|
|
2651
|
+
? [localValue]
|
|
2652
|
+
: [];
|
|
2653
|
+
return {
|
|
2654
|
+
proposedValue: values,
|
|
2655
|
+
builderValueJson: JSON.stringify(
|
|
2656
|
+
values.length === 1 ? values[0] : null,
|
|
2657
|
+
),
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
if (type === "multi_select" && Array.isArray(localValue)) {
|
|
2661
|
+
const labels = localValue
|
|
2662
|
+
.filter((value): value is string => typeof value === "string")
|
|
2663
|
+
.map((value) => optionNameById.get(value) ?? value);
|
|
2664
|
+
return {
|
|
2665
|
+
proposedValue: labels,
|
|
2666
|
+
builderValueJson: JSON.stringify(labels),
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
if (
|
|
2670
|
+
(sourceType === "datetime" || sourceType === "date") &&
|
|
2671
|
+
localValue &&
|
|
2672
|
+
typeof localValue === "object" &&
|
|
2673
|
+
!Array.isArray(localValue) &&
|
|
2674
|
+
typeof (localValue as { start?: unknown }).start === "string"
|
|
2675
|
+
) {
|
|
2676
|
+
return {
|
|
2677
|
+
proposedValue: localValue as DocumentPropertyValue,
|
|
2678
|
+
builderValueJson: JSON.stringify(
|
|
2679
|
+
(localValue as { start: string }).start,
|
|
2680
|
+
),
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
return {
|
|
2684
|
+
proposedValue: (localValue ?? null) as DocumentPropertyValue,
|
|
2685
|
+
builderValueJson: JSON.stringify(localValue ?? null),
|
|
2686
|
+
};
|
|
2687
|
+
};
|
|
2106
2688
|
for (const row of args.rowRows) {
|
|
2107
2689
|
if (
|
|
2108
2690
|
skipFixtureRows &&
|
|
@@ -2111,7 +2693,15 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2111
2693
|
continue;
|
|
2112
2694
|
}
|
|
2113
2695
|
|
|
2114
|
-
const
|
|
2696
|
+
const rowSourceValues =
|
|
2697
|
+
parseObject<Record<string, DocumentPropertyValue>>(
|
|
2698
|
+
row.sourceValuesJson,
|
|
2699
|
+
) ?? {};
|
|
2700
|
+
const sourceTitleValue = rowSourceValues["data.title"];
|
|
2701
|
+
const sourceTitle =
|
|
2702
|
+
typeof sourceTitleValue === "string" && sourceTitleValue.trim()
|
|
2703
|
+
? sourceTitleValue.trim()
|
|
2704
|
+
: row.sourceDisplayKey.trim();
|
|
2115
2705
|
const localTitle = args.documentTitleById.get(row.documentId)?.trim() ?? "";
|
|
2116
2706
|
const fieldChanges: ContentDatabaseSourceFieldChange[] = [];
|
|
2117
2707
|
if (localTitle && localTitle !== sourceTitle) {
|
|
@@ -2129,18 +2719,20 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2129
2719
|
// local value means "not loaded", not "cleared" — skip it.
|
|
2130
2720
|
const rowLocalValues = args.localValuesByDocument?.get(row.documentId);
|
|
2131
2721
|
if (rowLocalValues) {
|
|
2132
|
-
const rowSourceValues =
|
|
2133
|
-
parseObject<Record<string, DocumentPropertyValue>>(
|
|
2134
|
-
row.sourceValuesJson,
|
|
2135
|
-
) ?? {};
|
|
2136
2722
|
for (const field of args.writableFields ?? []) {
|
|
2137
2723
|
if (!rowLocalValues.has(field.localFieldKey)) continue;
|
|
2138
2724
|
const localValue = rowLocalValues.get(field.localFieldKey);
|
|
2139
2725
|
const baseValue = rowSourceValues[field.sourceFieldKey];
|
|
2726
|
+
const comparisonBaseValue =
|
|
2727
|
+
field.sourceFieldType?.trim().toLowerCase() === "reference"
|
|
2728
|
+
? (rowSourceValues[
|
|
2729
|
+
builderReferenceIdSourceValueKey(field.sourceFieldKey)
|
|
2730
|
+
] ?? baseValue)
|
|
2731
|
+
: baseValue;
|
|
2140
2732
|
if (
|
|
2141
2733
|
sameMappedSourceFieldValue(
|
|
2142
2734
|
localValue,
|
|
2143
|
-
|
|
2735
|
+
comparisonBaseValue,
|
|
2144
2736
|
field.propertyType,
|
|
2145
2737
|
field.propertyOptions,
|
|
2146
2738
|
)
|
|
@@ -2159,7 +2751,7 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2159
2751
|
field.propertyOptions,
|
|
2160
2752
|
)
|
|
2161
2753
|
: (baseValue ?? null)) as DocumentPropertyValue,
|
|
2162
|
-
|
|
2754
|
+
...reviewableBuilderValue(field, localValue),
|
|
2163
2755
|
});
|
|
2164
2756
|
}
|
|
2165
2757
|
}
|
|
@@ -2206,8 +2798,12 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2206
2798
|
|
|
2207
2799
|
const now = new Date().toISOString();
|
|
2208
2800
|
const displayTitle = localTitle || sourceTitle;
|
|
2209
|
-
|
|
2210
|
-
|
|
2801
|
+
const candidate: ContentDatabaseSourceChangeSet = {
|
|
2802
|
+
// Keep the synthetic identity stable while body hydration catches up.
|
|
2803
|
+
// The lightweight UI snapshot may initially see only field changes while
|
|
2804
|
+
// the authoritative write snapshot also sees a body diff. The payload
|
|
2805
|
+
// fingerprint, not an ID suffix, distinguishes material revisions.
|
|
2806
|
+
id: `local-pending-${row.id}-change`,
|
|
2211
2807
|
databaseItemId: row.databaseItemId,
|
|
2212
2808
|
documentId: row.documentId,
|
|
2213
2809
|
kind:
|
|
@@ -2234,7 +2830,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2234
2830
|
executions: [],
|
|
2235
2831
|
createdAt: now,
|
|
2236
2832
|
updatedAt: now,
|
|
2237
|
-
}
|
|
2833
|
+
};
|
|
2834
|
+
if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
|
|
2238
2835
|
}
|
|
2239
2836
|
|
|
2240
2837
|
// New-row creates: a local database item NOT linked to a Builder entry (no
|
|
@@ -2297,12 +2894,14 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2297
2894
|
localFieldKey: field.localFieldKey,
|
|
2298
2895
|
sourceFieldKey: field.sourceFieldKey,
|
|
2299
2896
|
currentValue: null,
|
|
2300
|
-
|
|
2301
|
-
|
|
2897
|
+
...reviewableBuilderValue(
|
|
2898
|
+
field,
|
|
2899
|
+
localValues.get(field.localFieldKey) ?? null,
|
|
2900
|
+
),
|
|
2302
2901
|
});
|
|
2303
2902
|
}
|
|
2304
2903
|
const now = new Date().toISOString();
|
|
2305
|
-
|
|
2904
|
+
const candidate: ContentDatabaseSourceChangeSet = {
|
|
2306
2905
|
id: `local-pending-create-${item.databaseItemId}`,
|
|
2307
2906
|
databaseItemId: item.databaseItemId,
|
|
2308
2907
|
documentId: item.documentId,
|
|
@@ -2323,7 +2922,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
|
|
|
2323
2922
|
executions: [],
|
|
2324
2923
|
createdAt: now,
|
|
2325
2924
|
updatedAt: now,
|
|
2326
|
-
}
|
|
2925
|
+
};
|
|
2926
|
+
if (!sameCancelledSnapshot(candidate)) pending.push(candidate);
|
|
2327
2927
|
}
|
|
2328
2928
|
}
|
|
2329
2929
|
|
|
@@ -2521,21 +3121,31 @@ async function readSourceSnapshotRowsOnce(args: {
|
|
|
2521
3121
|
...databaseItemRows.map((item) => item.documentId),
|
|
2522
3122
|
]),
|
|
2523
3123
|
);
|
|
2524
|
-
const rowDocuments
|
|
3124
|
+
const rowDocuments: Array<{
|
|
3125
|
+
id: string;
|
|
3126
|
+
title: string;
|
|
3127
|
+
content?: string;
|
|
3128
|
+
}> =
|
|
2525
3129
|
allDocumentIds.length > 0
|
|
2526
|
-
?
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
)
|
|
2538
|
-
|
|
3130
|
+
? args.includeHeavyBuilderBodyValues
|
|
3131
|
+
? await db
|
|
3132
|
+
.select(sourceSnapshotDocumentSelection(true))
|
|
3133
|
+
.from(schema.documents)
|
|
3134
|
+
.where(
|
|
3135
|
+
and(
|
|
3136
|
+
inArray(schema.documents.id, allDocumentIds),
|
|
3137
|
+
eq(schema.documents.ownerEmail, args.source.ownerEmail),
|
|
3138
|
+
),
|
|
3139
|
+
)
|
|
3140
|
+
: await db
|
|
3141
|
+
.select(sourceSnapshotDocumentSelection(false))
|
|
3142
|
+
.from(schema.documents)
|
|
3143
|
+
.where(
|
|
3144
|
+
and(
|
|
3145
|
+
inArray(schema.documents.id, allDocumentIds),
|
|
3146
|
+
eq(schema.documents.ownerEmail, args.source.ownerEmail),
|
|
3147
|
+
),
|
|
3148
|
+
)
|
|
2539
3149
|
: [];
|
|
2540
3150
|
const propertyValueRows =
|
|
2541
3151
|
args.isBuilderSource && allDocumentIds.length > 0
|
|
@@ -2687,13 +3297,19 @@ async function loadSourceSnapshot(
|
|
|
2687
3297
|
const propertyOptionsById = new Map(
|
|
2688
3298
|
propertyDefs.map((row) => [row.id, parsePropertyOptions(row.optionsJson)]),
|
|
2689
3299
|
);
|
|
3300
|
+
const builderModelFieldBySourceKey = new Map(
|
|
3301
|
+
(
|
|
3302
|
+
parseObject<SourceMetadataRecord>(source.metadataJson)
|
|
3303
|
+
?.builderModelFields ?? []
|
|
3304
|
+
).map((field) => [`data.${field.name.trim()}`, field]),
|
|
3305
|
+
);
|
|
2690
3306
|
const fields = fieldRows.map((row) =>
|
|
2691
3307
|
serializeSourceField(
|
|
2692
3308
|
row,
|
|
2693
3309
|
row.propertyId ? (propertyNameById.get(row.propertyId) ?? null) : null,
|
|
2694
3310
|
),
|
|
2695
3311
|
);
|
|
2696
|
-
|
|
3312
|
+
let storedChangeSets = changeRows.map(serializeSourceChangeSet);
|
|
2697
3313
|
const reviewEventsByChangeSetId = new Map<
|
|
2698
3314
|
string,
|
|
2699
3315
|
ContentDatabaseSourceReviewEvent[]
|
|
@@ -2765,6 +3381,9 @@ async function loadSourceSnapshot(
|
|
|
2765
3381
|
propertyOptions: row.propertyId
|
|
2766
3382
|
? propertyOptionsById.get(row.propertyId)
|
|
2767
3383
|
: undefined,
|
|
3384
|
+
sourceFieldType: row.sourceFieldType,
|
|
3385
|
+
sourceFieldModel: builderModelFieldBySourceKey.get(row.sourceFieldKey)
|
|
3386
|
+
?.model,
|
|
2768
3387
|
}));
|
|
2769
3388
|
// Row-union ownership scoping (Builder only). Determine which documents belong
|
|
2770
3389
|
// to OTHER sources and whether this source is the primary (oldest), so the
|
|
@@ -2845,11 +3464,19 @@ async function loadSourceSnapshot(
|
|
|
2845
3464
|
);
|
|
2846
3465
|
await Promise.all(
|
|
2847
3466
|
allDocumentIds.map(async (documentId) => {
|
|
2848
|
-
if (!hydratedDocumentIds.has(documentId)) return;
|
|
2849
3467
|
const row = sourceRowByDocumentId.get(documentId);
|
|
2850
|
-
|
|
2851
|
-
|
|
3468
|
+
let bodyChange: ContentDatabaseSourceBodyChange | null = null;
|
|
3469
|
+
// Only the primary source may adopt a genuinely local row with no source
|
|
3470
|
+
// identity. Synthetic fixture rows already belong to this source, but
|
|
3471
|
+
// still represent create_draft targets rather than imported baselines.
|
|
3472
|
+
const allowUnsourcedCreate =
|
|
3473
|
+
isPrimarySource &&
|
|
3474
|
+
!otherSourceDocumentIds.has(documentId) &&
|
|
3475
|
+
!taggedSourceByDocumentId.has(documentId);
|
|
3476
|
+
bodyChange = await builderBodyChangeForSourceSnapshotDocument({
|
|
2852
3477
|
row,
|
|
3478
|
+
isHydrated: hydratedDocumentIds.has(documentId),
|
|
3479
|
+
allowUnsourcedCreate,
|
|
2853
3480
|
localContent: documentContentById.get(documentId),
|
|
2854
3481
|
});
|
|
2855
3482
|
if (bodyChange) bodyChangeByDocumentId.set(documentId, bodyChange);
|
|
@@ -2857,6 +3484,77 @@ async function loadSourceSnapshot(
|
|
|
2857
3484
|
);
|
|
2858
3485
|
}
|
|
2859
3486
|
|
|
3487
|
+
// A locally blocked dry run never crossed the Builder dispatch boundary, so
|
|
3488
|
+
// its approved body payload may be refreshed from the current document. This
|
|
3489
|
+
// is deliberately narrower than ordinary "retryable" state: running,
|
|
3490
|
+
// response-bearing, failed, reconciliatory, and otherwise ambiguous gates
|
|
3491
|
+
// retain the exact body that was originally approved.
|
|
3492
|
+
if (isBuilderSource && bodyChangeByDocumentId.size > 0) {
|
|
3493
|
+
const executionRowsByChangeSetId = new Map<
|
|
3494
|
+
string,
|
|
3495
|
+
ContentDatabaseSourceExecutionRowDb[]
|
|
3496
|
+
>();
|
|
3497
|
+
for (const execution of executionRows) {
|
|
3498
|
+
const rows = executionRowsByChangeSetId.get(execution.changeSetId) ?? [];
|
|
3499
|
+
rows.push(execution);
|
|
3500
|
+
executionRowsByChangeSetId.set(execution.changeSetId, rows);
|
|
3501
|
+
}
|
|
3502
|
+
storedChangeSets = storedChangeSets.map((changeSet) => {
|
|
3503
|
+
// Rejected snapshots are durable review/audit evidence. In particular,
|
|
3504
|
+
// a cancelled gate must retain the exact diff the user cancelled so a
|
|
3505
|
+
// later local or remote body change can be distinguished from it.
|
|
3506
|
+
if (changeSet.state !== "approved") return changeSet;
|
|
3507
|
+
const currentBody = changeSet.documentId
|
|
3508
|
+
? bodyChangeByDocumentId.get(changeSet.documentId)
|
|
3509
|
+
: null;
|
|
3510
|
+
if (!currentBody) return changeSet;
|
|
3511
|
+
const executions = executionRowsByChangeSetId.get(changeSet.id) ?? [];
|
|
3512
|
+
if (!canRefreshLocallyBlockedBuilderReview(executions)) return changeSet;
|
|
3513
|
+
return { ...changeSet, bodyChange: currentBody };
|
|
3514
|
+
});
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
const cancellationReviewChangeSetIds = new Set(
|
|
3518
|
+
reviewRows
|
|
3519
|
+
.filter(
|
|
3520
|
+
(review) =>
|
|
3521
|
+
review.decision === "rejected" &&
|
|
3522
|
+
review.stateTo === "rejected" &&
|
|
3523
|
+
review.note?.startsWith(CANCELLED_BUILDER_REVIEW_NOTE_PREFIX),
|
|
3524
|
+
)
|
|
3525
|
+
.map((review) => review.changeSetId),
|
|
3526
|
+
);
|
|
3527
|
+
const executionRowsByChangeSetId = new Map<
|
|
3528
|
+
string,
|
|
3529
|
+
ContentDatabaseSourceExecutionRowDb[]
|
|
3530
|
+
>();
|
|
3531
|
+
for (const execution of executionRows) {
|
|
3532
|
+
const rows = executionRowsByChangeSetId.get(execution.changeSetId) ?? [];
|
|
3533
|
+
rows.push(execution);
|
|
3534
|
+
executionRowsByChangeSetId.set(execution.changeSetId, rows);
|
|
3535
|
+
}
|
|
3536
|
+
const cancelledRejectedChangeSetIds = new Set(
|
|
3537
|
+
storedChangeSets
|
|
3538
|
+
.filter((changeSet) => {
|
|
3539
|
+
if (
|
|
3540
|
+
changeSet.state !== "rejected" ||
|
|
3541
|
+
!cancellationReviewChangeSetIds.has(changeSet.id)
|
|
3542
|
+
) {
|
|
3543
|
+
return false;
|
|
3544
|
+
}
|
|
3545
|
+
const executions = executionRowsByChangeSetId.get(changeSet.id) ?? [];
|
|
3546
|
+
return (
|
|
3547
|
+
executions.length > 0 &&
|
|
3548
|
+
executions.every(
|
|
3549
|
+
(execution) =>
|
|
3550
|
+
execution.state === "blocked" &&
|
|
3551
|
+
execution.summary === CANCELLED_BUILDER_EXECUTION_SUMMARY,
|
|
3552
|
+
)
|
|
3553
|
+
);
|
|
3554
|
+
})
|
|
3555
|
+
.map((changeSet) => changeSet.id),
|
|
3556
|
+
);
|
|
3557
|
+
|
|
2860
3558
|
const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({
|
|
2861
3559
|
source,
|
|
2862
3560
|
rowRows,
|
|
@@ -2877,10 +3575,11 @@ async function loadSourceSnapshot(
|
|
|
2877
3575
|
.filter((row) => row.provenance === "Builder CMS read adapter")
|
|
2878
3576
|
.map((row) => row.documentId),
|
|
2879
3577
|
),
|
|
3578
|
+
cancelledRejectedChangeSetIds,
|
|
2880
3579
|
});
|
|
2881
3580
|
const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row]));
|
|
2882
|
-
const changeSets = [
|
|
2883
|
-
(changeSet) =>
|
|
3581
|
+
const changeSets = [
|
|
3582
|
+
...storedChangeSets.map((changeSet) =>
|
|
2884
3583
|
reviewedChangeSet({
|
|
2885
3584
|
changeSet,
|
|
2886
3585
|
source,
|
|
@@ -2888,7 +3587,20 @@ async function loadSourceSnapshot(
|
|
|
2888
3587
|
reviewEvents: reviewEventsByChangeSetId.get(changeSet.id) ?? [],
|
|
2889
3588
|
executions: executionsByChangeSetId.get(changeSet.id) ?? [],
|
|
2890
3589
|
}),
|
|
2891
|
-
|
|
3590
|
+
),
|
|
3591
|
+
...localOutboundChangeSets.map((changeSet) =>
|
|
3592
|
+
reviewedChangeSet({
|
|
3593
|
+
changeSet,
|
|
3594
|
+
source,
|
|
3595
|
+
rowByDocumentId,
|
|
3596
|
+
// Synthetic current diffs deliberately reuse a stable identity. A
|
|
3597
|
+
// closed stored revision with that ID is historical evidence, not the
|
|
3598
|
+
// execution state of this new payload.
|
|
3599
|
+
reviewEvents: [],
|
|
3600
|
+
executions: [],
|
|
3601
|
+
}),
|
|
3602
|
+
),
|
|
3603
|
+
];
|
|
2892
3604
|
const metadata = parseObject<SourceMetadataRecord>(source.metadataJson) ?? {};
|
|
2893
3605
|
const normalizedWriteMode =
|
|
2894
3606
|
metadata.writeMode === "read_only" ||
|
|
@@ -2989,6 +3701,7 @@ async function loadSourceSnapshot(
|
|
|
2989
3701
|
.map((mode) => normalizePushMode(mode))
|
|
2990
3702
|
.filter((mode): mode is ContentDatabaseSourcePushMode => !!mode)
|
|
2991
3703
|
: undefined,
|
|
3704
|
+
builderModelFields: metadata.builderModelFields,
|
|
2992
3705
|
federation: normalizeSourceFederation(metadata.federation),
|
|
2993
3706
|
},
|
|
2994
3707
|
fields,
|
|
@@ -3035,6 +3748,7 @@ async function sourceBodyHydrationSummary(args: {
|
|
|
3035
3748
|
pending: 0,
|
|
3036
3749
|
hydrating: 0,
|
|
3037
3750
|
hydrated: 0,
|
|
3751
|
+
unavailable: 0,
|
|
3038
3752
|
error: 0,
|
|
3039
3753
|
total: rows.length,
|
|
3040
3754
|
};
|
|
@@ -3042,6 +3756,7 @@ async function sourceBodyHydrationSummary(args: {
|
|
|
3042
3756
|
if (row.status === "pending" || (!row.status && row.queueId)) {
|
|
3043
3757
|
summary.pending += 1;
|
|
3044
3758
|
} else if (row.status === "hydrating") summary.hydrating += 1;
|
|
3759
|
+
else if (row.status === "unavailable") summary.unavailable! += 1;
|
|
3045
3760
|
else if (row.status === "error") summary.error += 1;
|
|
3046
3761
|
else summary.hydrated += 1;
|
|
3047
3762
|
}
|
|
@@ -3158,6 +3873,7 @@ export function serializeBuilderCmsSourceReadMetadataRecord(args: {
|
|
|
3158
3873
|
);
|
|
3159
3874
|
return JSON.stringify({
|
|
3160
3875
|
...builderCmsSourceMetadata(args.sourceTable),
|
|
3876
|
+
...existingMetadata,
|
|
3161
3877
|
builderModelFields:
|
|
3162
3878
|
args.builderModelFields ?? existingMetadata?.builderModelFields,
|
|
3163
3879
|
readMode: args.readState === "live" ? "builder-api" : "fixture",
|
|
@@ -3259,6 +3975,41 @@ function normalizeBuilderCmsSourceFieldType(type: string) {
|
|
|
3259
3975
|
return normalized || "text";
|
|
3260
3976
|
}
|
|
3261
3977
|
|
|
3978
|
+
function isBuilderReferenceModelField(field: BuilderCmsModelFieldSummary) {
|
|
3979
|
+
return [field.type, field.inputType]
|
|
3980
|
+
.filter((value): value is string => typeof value === "string")
|
|
3981
|
+
.some((value) => /\b(reference|relation)\b/i.test(value));
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/**
|
|
3985
|
+
* A raw Builder entry can teach required-field setup the reference model even
|
|
3986
|
+
* when Builder's model endpoint omits it. Refreshes must not throw that
|
|
3987
|
+
* provider-native enrichment away: the canonical local value is only an entry
|
|
3988
|
+
* id, and dispatch needs the learned model to reconstruct a Builder reference.
|
|
3989
|
+
*/
|
|
3990
|
+
export function mergeBuilderCmsModelFieldsPreservingReferenceModels(args: {
|
|
3991
|
+
existing?: BuilderCmsModelFieldSummary[];
|
|
3992
|
+
refreshed?: BuilderCmsModelFieldSummary[];
|
|
3993
|
+
}) {
|
|
3994
|
+
if (!args.refreshed || args.refreshed.length === 0) {
|
|
3995
|
+
return args.existing ?? args.refreshed;
|
|
3996
|
+
}
|
|
3997
|
+
const existingByName = new Map(
|
|
3998
|
+
(args.existing ?? []).map((field) => [field.name.trim(), field]),
|
|
3999
|
+
);
|
|
4000
|
+
return args.refreshed.map((field) => {
|
|
4001
|
+
const existing = existingByName.get(field.name.trim());
|
|
4002
|
+
if (
|
|
4003
|
+
field.model?.trim() ||
|
|
4004
|
+
!existing?.model?.trim() ||
|
|
4005
|
+
!isBuilderReferenceModelField(field)
|
|
4006
|
+
) {
|
|
4007
|
+
return field;
|
|
4008
|
+
}
|
|
4009
|
+
return { ...field, model: existing.model.trim() };
|
|
4010
|
+
});
|
|
4011
|
+
}
|
|
4012
|
+
|
|
3262
4013
|
export async function seedMockSourceFields(args: {
|
|
3263
4014
|
sourceId: string;
|
|
3264
4015
|
ownerEmail: string;
|
|
@@ -3276,6 +4027,12 @@ export async function seedMockSourceFields(args: {
|
|
|
3276
4027
|
.filter((field) => isBuilder && field.propertyId)
|
|
3277
4028
|
.map((field) => [field.propertyId!, field]),
|
|
3278
4029
|
);
|
|
4030
|
+
const builderModelFieldBySourceKey = new Map(
|
|
4031
|
+
(args.builderModelFields ?? []).map((field) => [
|
|
4032
|
+
`data.${field.name.trim()}`,
|
|
4033
|
+
field,
|
|
4034
|
+
]),
|
|
4035
|
+
);
|
|
3279
4036
|
const rows = [
|
|
3280
4037
|
{
|
|
3281
4038
|
id: crypto.randomUUID(),
|
|
@@ -3386,7 +4143,18 @@ export async function seedMockSourceFields(args: {
|
|
|
3386
4143
|
))
|
|
3387
4144
|
: `fields.${slugifySourceField(property.definition.name)}`,
|
|
3388
4145
|
sourceFieldLabel: property.definition.name,
|
|
3389
|
-
sourceFieldType:
|
|
4146
|
+
sourceFieldType: isBuilder
|
|
4147
|
+
? normalizeBuilderCmsSourceFieldType(
|
|
4148
|
+
builderModelFieldBySourceKey.get(
|
|
4149
|
+
existingBuilderFieldByPropertyId.get(property.definition.id)
|
|
4150
|
+
?.sourceFieldKey ??
|
|
4151
|
+
builderCmsSourceFieldKey(
|
|
4152
|
+
property.definition.id,
|
|
4153
|
+
property.definition.name,
|
|
4154
|
+
),
|
|
4155
|
+
)?.type ?? property.definition.type,
|
|
4156
|
+
)
|
|
4157
|
+
: property.definition.type,
|
|
3390
4158
|
mappingType: "property",
|
|
3391
4159
|
writeOwner:
|
|
3392
4160
|
property.definition.type === "created_time" ||
|
|
@@ -3525,65 +4293,77 @@ export async function seedMockSourceRows(args: {
|
|
|
3525
4293
|
}) {
|
|
3526
4294
|
if (args.items.length === 0) return;
|
|
3527
4295
|
const db = getDb();
|
|
3528
|
-
await db
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
4296
|
+
await db
|
|
4297
|
+
.insert(schema.contentDatabaseSourceRows)
|
|
4298
|
+
.values(
|
|
4299
|
+
args.items.map((item, index) => {
|
|
4300
|
+
const builderEntry = args.builderEntriesByDocumentId?.get(
|
|
4301
|
+
item.document.id,
|
|
4302
|
+
);
|
|
4303
|
+
const existingBuilderRow = args.existingBuilderRows?.get(
|
|
4304
|
+
item.document.id,
|
|
4305
|
+
);
|
|
4306
|
+
const builderIdentity =
|
|
4307
|
+
args.sourceType === "builder-cms"
|
|
4308
|
+
? builderCmsSourceRowIdentity({
|
|
4309
|
+
item,
|
|
4310
|
+
sourceTable: args.sourceTable,
|
|
4311
|
+
now: args.now,
|
|
4312
|
+
existing: existingBuilderRow,
|
|
4313
|
+
entry: builderEntry,
|
|
4314
|
+
})
|
|
4315
|
+
: null;
|
|
4316
|
+
const sourceQualifiedId = builderIdentity
|
|
4317
|
+
? builderIdentity.sourceQualifiedId
|
|
4318
|
+
: `${args.sourceType}://${args.sourceTable}/${item.document.id}`;
|
|
4319
|
+
return {
|
|
4320
|
+
id: builderIdentity
|
|
4321
|
+
? stableBuilderImportId("builder-row", [
|
|
4322
|
+
args.sourceId,
|
|
4323
|
+
sourceQualifiedId,
|
|
4324
|
+
item.document.id,
|
|
4325
|
+
])
|
|
4326
|
+
: crypto.randomUUID(),
|
|
4327
|
+
ownerEmail: args.ownerEmail,
|
|
4328
|
+
sourceId: args.sourceId,
|
|
4329
|
+
databaseItemId: item.id,
|
|
4330
|
+
documentId: item.document.id,
|
|
4331
|
+
sourceRowId: builderIdentity
|
|
4332
|
+
? builderIdentity.sourceRowId
|
|
4333
|
+
: `${args.sourceType}-${item.document.id}`,
|
|
4334
|
+
sourceQualifiedId,
|
|
4335
|
+
sourceDisplayKey:
|
|
4336
|
+
builderIdentity?.sourceDisplayKey ??
|
|
4337
|
+
item.document.title?.trim() ??
|
|
4338
|
+
`${args.sourceType}-${index + 1}`,
|
|
4339
|
+
sourceValuesJson: JSON.stringify(
|
|
4340
|
+
sourceValuesForSeededSourceRow({
|
|
4341
|
+
sourceType: args.sourceType,
|
|
3539
4342
|
item,
|
|
3540
4343
|
sourceTable: args.sourceTable,
|
|
3541
4344
|
now: args.now,
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
:
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
:
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
sourceType: args.sourceType,
|
|
3565
|
-
item,
|
|
3566
|
-
sourceTable: args.sourceTable,
|
|
3567
|
-
now: args.now,
|
|
3568
|
-
builderEntry,
|
|
3569
|
-
existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
|
|
3570
|
-
}),
|
|
3571
|
-
),
|
|
3572
|
-
provenance:
|
|
3573
|
-
args.sourceType === "builder-cms"
|
|
3574
|
-
? args.builderEntriesByDocumentId?.has(item.document.id)
|
|
3575
|
-
? "Builder CMS read adapter"
|
|
3576
|
-
: BUILDER_CMS_FIXTURE_ROW_PROVENANCE
|
|
3577
|
-
: "mock source row",
|
|
3578
|
-
syncState: "linked",
|
|
3579
|
-
freshness: "fresh",
|
|
3580
|
-
lastSyncedAt: args.now,
|
|
3581
|
-
lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
|
|
3582
|
-
createdAt: args.now,
|
|
3583
|
-
updatedAt: args.now,
|
|
3584
|
-
};
|
|
3585
|
-
}),
|
|
3586
|
-
);
|
|
4345
|
+
builderEntry,
|
|
4346
|
+
existingSourceValuesJson: existingBuilderRow?.sourceValuesJson,
|
|
4347
|
+
existingLastSourceUpdatedAt:
|
|
4348
|
+
existingBuilderRow?.lastSourceUpdatedAt,
|
|
4349
|
+
}),
|
|
4350
|
+
),
|
|
4351
|
+
provenance:
|
|
4352
|
+
args.sourceType === "builder-cms"
|
|
4353
|
+
? args.builderEntriesByDocumentId?.has(item.document.id)
|
|
4354
|
+
? "Builder CMS read adapter"
|
|
4355
|
+
: BUILDER_CMS_FIXTURE_ROW_PROVENANCE
|
|
4356
|
+
: "mock source row",
|
|
4357
|
+
syncState: "linked",
|
|
4358
|
+
freshness: "fresh",
|
|
4359
|
+
lastSyncedAt: args.now,
|
|
4360
|
+
lastSourceUpdatedAt: builderIdentity?.lastSourceUpdatedAt ?? args.now,
|
|
4361
|
+
createdAt: args.now,
|
|
4362
|
+
updatedAt: args.now,
|
|
4363
|
+
};
|
|
4364
|
+
}),
|
|
4365
|
+
)
|
|
4366
|
+
.onConflictDoNothing();
|
|
3587
4367
|
}
|
|
3588
4368
|
|
|
3589
4369
|
export function sourceValuesForSeededSourceRow(args: {
|
|
@@ -3593,15 +4373,40 @@ export function sourceValuesForSeededSourceRow(args: {
|
|
|
3593
4373
|
now: string;
|
|
3594
4374
|
builderEntry?: BuilderCmsSourceEntry | null;
|
|
3595
4375
|
existingSourceValuesJson?: string | null;
|
|
4376
|
+
existingLastSourceUpdatedAt?: string | null;
|
|
3596
4377
|
}): Record<string, DocumentPropertyValue> {
|
|
3597
4378
|
const existingSourceValues = parseObject<
|
|
3598
4379
|
Record<string, DocumentPropertyValue>
|
|
3599
4380
|
>(args.existingSourceValuesJson);
|
|
3600
4381
|
if (args.builderEntry?.sourceValues) {
|
|
3601
|
-
|
|
4382
|
+
const values = builderSourceValuesWithPreservedBodyBaseline({
|
|
3602
4383
|
incoming: args.builderEntry.sourceValues,
|
|
3603
4384
|
existing: existingSourceValues,
|
|
4385
|
+
existingLastSourceUpdatedAt: args.existingLastSourceUpdatedAt,
|
|
3604
4386
|
});
|
|
4387
|
+
const rawData = args.builderEntry.rawEntry?.data;
|
|
4388
|
+
if (rawData && typeof rawData === "object" && !Array.isArray(rawData)) {
|
|
4389
|
+
for (const [name, rawValue] of Object.entries(rawData)) {
|
|
4390
|
+
if (
|
|
4391
|
+
!rawValue ||
|
|
4392
|
+
typeof rawValue !== "object" ||
|
|
4393
|
+
Array.isArray(rawValue)
|
|
4394
|
+
)
|
|
4395
|
+
continue;
|
|
4396
|
+
const reference = rawValue as Record<string, unknown>;
|
|
4397
|
+
if (
|
|
4398
|
+
reference["@type"] !== "@builder.io/core:Reference" ||
|
|
4399
|
+
typeof reference.id !== "string" ||
|
|
4400
|
+
!reference.id.trim()
|
|
4401
|
+
) {
|
|
4402
|
+
continue;
|
|
4403
|
+
}
|
|
4404
|
+
const sourceFieldKey = `data.${name}`;
|
|
4405
|
+
values[builderReferenceIdSourceValueKey(sourceFieldKey)] =
|
|
4406
|
+
reference.id.trim();
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
return values;
|
|
3605
4410
|
}
|
|
3606
4411
|
if (existingSourceValues) return existingSourceValues;
|
|
3607
4412
|
if (args.sourceType !== "builder-cms") return {};
|
|
@@ -3615,6 +4420,7 @@ export function sourceValuesForSeededSourceRow(args: {
|
|
|
3615
4420
|
function builderSourceValuesWithPreservedBodyBaseline(args: {
|
|
3616
4421
|
incoming: Record<string, DocumentPropertyValue>;
|
|
3617
4422
|
existing?: Record<string, DocumentPropertyValue> | null;
|
|
4423
|
+
existingLastSourceUpdatedAt?: string | null;
|
|
3618
4424
|
}) {
|
|
3619
4425
|
const existing = args.existing;
|
|
3620
4426
|
if (!existing) return args.incoming;
|
|
@@ -3634,24 +4440,66 @@ function builderSourceValuesWithPreservedBodyBaseline(args: {
|
|
|
3634
4440
|
existing,
|
|
3635
4441
|
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
3636
4442
|
);
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
(
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
4443
|
+
const incomingLastUpdated =
|
|
4444
|
+
stringSourceValue(args.incoming, "lastUpdated") ?? null;
|
|
4445
|
+
const existingLastUpdated =
|
|
4446
|
+
stringSourceValue(existing, BUILDER_CMS_BODY_LAST_UPDATED_KEY) ??
|
|
4447
|
+
stringSourceValue(existing, "lastUpdated") ??
|
|
4448
|
+
args.existingLastSourceUpdatedAt ??
|
|
4449
|
+
null;
|
|
4450
|
+
const incomingEpoch = builderTimestampEpoch(incomingLastUpdated);
|
|
4451
|
+
const existingEpoch = builderTimestampEpoch(existingLastUpdated);
|
|
4452
|
+
const sameRemoteVersion =
|
|
4453
|
+
incomingEpoch !== null &&
|
|
4454
|
+
existingEpoch !== null &&
|
|
4455
|
+
incomingEpoch === existingEpoch;
|
|
3645
4456
|
const next = { ...args.incoming };
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
4457
|
+
const preserveExistingBody =
|
|
4458
|
+
!incomingContent?.trim() &&
|
|
4459
|
+
!!existingContent?.trim() &&
|
|
4460
|
+
(sameRemoteVersion ||
|
|
4461
|
+
!(incomingHash && existingHash && incomingHash !== existingHash));
|
|
4462
|
+
if (preserveExistingBody) {
|
|
4463
|
+
for (const key of [
|
|
4464
|
+
BUILDER_CMS_BODY_CONTENT_KEY,
|
|
4465
|
+
BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
|
|
4466
|
+
BUILDER_CMS_BODY_READABLE_MAP_KEY,
|
|
4467
|
+
BUILDER_CMS_BODY_SIDECARS_KEY,
|
|
4468
|
+
BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
|
|
4469
|
+
BUILDER_CMS_BODY_LAST_UPDATED_KEY,
|
|
4470
|
+
]) {
|
|
4471
|
+
if (existing[key] !== undefined) {
|
|
4472
|
+
next[key] = existing[key];
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
if (
|
|
4476
|
+
next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] === undefined &&
|
|
4477
|
+
args.existingLastSourceUpdatedAt
|
|
4478
|
+
) {
|
|
4479
|
+
next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] =
|
|
4480
|
+
args.existingLastSourceUpdatedAt;
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
// Required-reference materialization stores the canonical Builder entry id
|
|
4484
|
+
// beside the readable projection. Refresh receives only that projection, so
|
|
4485
|
+
// retain the id while the projected value is unchanged. If the remote label
|
|
4486
|
+
// changes, discard the old id instead of pairing stale identity with it.
|
|
4487
|
+
const referenceIdPrefix = "__agent_native_builder_reference_id:";
|
|
4488
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
4489
|
+
if (!key.startsWith(referenceIdPrefix)) continue;
|
|
4490
|
+
const sourceFieldKey = key.slice(referenceIdPrefix.length);
|
|
4491
|
+
if (
|
|
4492
|
+
stableValueString(existing[sourceFieldKey]) ===
|
|
4493
|
+
stableValueString(args.incoming[sourceFieldKey])
|
|
4494
|
+
) {
|
|
4495
|
+
next[key] = value;
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
if (next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] === undefined) {
|
|
4499
|
+
const existingBaselineLastUpdated =
|
|
4500
|
+
existing[BUILDER_CMS_BODY_LAST_UPDATED_KEY] ?? existing.lastUpdated;
|
|
4501
|
+
if (existingBaselineLastUpdated !== undefined) {
|
|
4502
|
+
next[BUILDER_CMS_BODY_LAST_UPDATED_KEY] = existingBaselineLastUpdated;
|
|
3655
4503
|
}
|
|
3656
4504
|
}
|
|
3657
4505
|
return next;
|
|
@@ -4114,13 +4962,19 @@ export function mapBuilderCmsEntriesToLocalItems(args: {
|
|
|
4114
4962
|
sourceTable: args.sourceTable,
|
|
4115
4963
|
now: args.now,
|
|
4116
4964
|
});
|
|
4965
|
+
const exactIdentityMatch = existing
|
|
4966
|
+
? (entriesById.get(existing.sourceRowId) ??
|
|
4967
|
+
entriesByQualifiedId.get(existing.sourceQualifiedId))
|
|
4968
|
+
: null;
|
|
4969
|
+
const allowNaturalKeyFallback =
|
|
4970
|
+
!existing ||
|
|
4971
|
+
builderCmsSourceRowIdentityState({ row: existing }).isSyntheticFixture;
|
|
4117
4972
|
const match =
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
entriesByTitle.get(item.document.title.trim().toLowerCase());
|
|
4973
|
+
exactIdentityMatch ??
|
|
4974
|
+
(allowNaturalKeyFallback
|
|
4975
|
+
? (entriesByUrlPath.get(fixtureEntry.urlPath.toLowerCase()) ??
|
|
4976
|
+
entriesByTitle.get(item.document.title.trim().toLowerCase()))
|
|
4977
|
+
: null);
|
|
4124
4978
|
if (match) entriesByDocumentId.set(item.document.id, match);
|
|
4125
4979
|
}
|
|
4126
4980
|
|
|
@@ -4207,8 +5061,18 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
|
|
|
4207
5061
|
eq(schema.documents.id, schema.contentDatabaseItems.documentId),
|
|
4208
5062
|
)
|
|
4209
5063
|
.where(eq(schema.contentDatabaseItems.databaseId, args.database.id));
|
|
4210
|
-
const
|
|
4211
|
-
|
|
5064
|
+
const representedDocumentIds = new Set(
|
|
5065
|
+
(args.existingSourceRows ?? [])
|
|
5066
|
+
.map((row) => row.documentId)
|
|
5067
|
+
.filter((documentId): documentId is string => Boolean(documentId)),
|
|
5068
|
+
);
|
|
5069
|
+
// Builder entry IDs, not titles, are the remote identity. Preserve every
|
|
5070
|
+
// same-title entry in a collection while retaining the legacy guard against
|
|
5071
|
+
// adopting an already-present local row during source replacement.
|
|
5072
|
+
const existingUnlinkedTitles = new Set(
|
|
5073
|
+
currentItems
|
|
5074
|
+
.filter((row) => !representedDocumentIds.has(row.document.id))
|
|
5075
|
+
.map((row) => row.document.title.trim().toLowerCase()),
|
|
4212
5076
|
);
|
|
4213
5077
|
|
|
4214
5078
|
// Reads MAX(position) for both `documents` and `content_database_items`
|
|
@@ -4257,11 +5121,24 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
|
|
|
4257
5121
|
|
|
4258
5122
|
const title = entry.title.trim() || entry.id;
|
|
4259
5123
|
const titleKey = title.toLowerCase();
|
|
4260
|
-
if (!args.skipTitleDedup &&
|
|
4261
|
-
|
|
5124
|
+
if (!args.skipTitleDedup && existingUnlinkedTitles.has(titleKey)) {
|
|
5125
|
+
continue;
|
|
5126
|
+
}
|
|
4262
5127
|
|
|
4263
|
-
const
|
|
4264
|
-
|
|
5128
|
+
const importIdentity = [
|
|
5129
|
+
args.database.ownerEmail,
|
|
5130
|
+
args.database.id,
|
|
5131
|
+
args.sourceTable,
|
|
5132
|
+
entry.id,
|
|
5133
|
+
];
|
|
5134
|
+
const documentId = stableBuilderImportId(
|
|
5135
|
+
"builder-doc",
|
|
5136
|
+
importIdentity,
|
|
5137
|
+
);
|
|
5138
|
+
const itemId = stableBuilderImportId(
|
|
5139
|
+
"builder-item",
|
|
5140
|
+
importIdentity,
|
|
5141
|
+
);
|
|
4265
5142
|
documentRows.push({
|
|
4266
5143
|
id: documentId,
|
|
4267
5144
|
ownerEmail: args.database.ownerEmail,
|
|
@@ -4296,13 +5173,19 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
|
|
|
4296
5173
|
documentRows,
|
|
4297
5174
|
bulkChunkSizeForColumnCount(13),
|
|
4298
5175
|
)) {
|
|
4299
|
-
await tx
|
|
5176
|
+
await tx
|
|
5177
|
+
.insert(schema.documents)
|
|
5178
|
+
.values(chunk)
|
|
5179
|
+
.onConflictDoNothing();
|
|
4300
5180
|
}
|
|
4301
5181
|
for (const chunk of chunks(
|
|
4302
5182
|
itemRows,
|
|
4303
5183
|
bulkChunkSizeForColumnCount(10),
|
|
4304
5184
|
)) {
|
|
4305
|
-
await tx
|
|
5185
|
+
await tx
|
|
5186
|
+
.insert(schema.contentDatabaseItems)
|
|
5187
|
+
.values(chunk)
|
|
5188
|
+
.onConflictDoNothing();
|
|
4306
5189
|
}
|
|
4307
5190
|
});
|
|
4308
5191
|
|
|
@@ -4346,6 +5229,10 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
|
|
|
4346
5229
|
builderModelFields = await readBuilderCmsModelFields({
|
|
4347
5230
|
model: args.source.sourceTable,
|
|
4348
5231
|
});
|
|
5232
|
+
builderModelFields = mergeBuilderCmsModelFieldsPreservingReferenceModels({
|
|
5233
|
+
existing: sourceMetadata.builderModelFields,
|
|
5234
|
+
refreshed: builderModelFields,
|
|
5235
|
+
});
|
|
4349
5236
|
} catch (error) {
|
|
4350
5237
|
builderModelFieldsReadFailed = true;
|
|
4351
5238
|
const message = error instanceof Error ? error.message : String(error);
|