@agent-native/core 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  6. package/corpus/templates/content/.env.local.example +6 -0
  7. package/corpus/templates/content/AGENTS.md +10 -1
  8. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  9. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  10. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  11. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  12. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  13. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  14. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  15. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  16. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  17. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  18. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  19. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  20. package/corpus/templates/content/actions/update-document.ts +93 -0
  21. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  22. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  23. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +45 -4
  24. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  25. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  26. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  27. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  28. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  29. package/corpus/templates/content/app/global.css +10 -3
  30. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  31. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  32. package/corpus/templates/content/app/i18n-data.ts +212 -0
  33. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  34. package/corpus/templates/content/app/root.tsx +236 -8
  35. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  36. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  37. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  38. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  39. package/corpus/templates/content/package.json +3 -1
  40. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  41. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  42. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  43. package/corpus/templates/content/server/db/schema.ts +27 -0
  44. package/corpus/templates/content/server/plugins/db.ts +33 -0
  45. package/corpus/templates/content/shared/api.ts +45 -0
  46. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  47. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  48. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  49. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  51. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  52. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  53. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  54. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  55. package/dist/client/CommandMenu.d.ts +6 -2
  56. package/dist/client/CommandMenu.d.ts.map +1 -1
  57. package/dist/client/CommandMenu.js +25 -14
  58. package/dist/client/CommandMenu.js.map +1 -1
  59. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  60. package/dist/notifications/routes.d.ts +2 -2
  61. package/dist/observability/routes.d.ts +8 -8
  62. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  63. package/package.json +1 -1
@@ -1,8 +1,10 @@
1
+ import { deleteCollabState, releaseDoc } from "@agent-native/core/collab";
1
2
  import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
2
3
 
3
4
  import { getDb, schema } from "../server/db/index.js";
4
5
  import type {
5
6
  ContentDatabase,
7
+ ContentDatabaseBodyHydrationSummary,
6
8
  ContentDatabaseItem,
7
9
  ContentDatabaseResponse,
8
10
  ContentDatabaseSource,
@@ -31,6 +33,14 @@ import type {
31
33
  DocumentProperty,
32
34
  DocumentPropertyValue,
33
35
  } from "../shared/api.js";
36
+ import {
37
+ builderBlocksHash,
38
+ builderBlocksToReadableMarkdown,
39
+ builderEntryToReadableMdxBundle,
40
+ builderEntryToMdxBundle,
41
+ builderReadableBodyToBuilderBlocks,
42
+ builderMdxBodyToBuilderBlocks,
43
+ } from "../shared/builder-mdx.js";
34
44
  import {
35
45
  parsePropertyOptions,
36
46
  serializePropertyOptions,
@@ -44,6 +54,11 @@ import {
44
54
  type BuilderCmsReadState,
45
55
  } from "./_builder-cms-read-client.js";
46
56
  import {
57
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
58
+ BUILDER_CMS_BODY_CONTENT_KEY,
59
+ BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
60
+ BUILDER_CMS_BODY_READABLE_MAP_KEY,
61
+ BUILDER_CMS_BODY_SIDECARS_KEY,
47
62
  BUILDER_CMS_FIXTURE_ROW_PROVENANCE,
48
63
  buildBuilderCmsFixtureEntry,
49
64
  builderCmsQualifiedId,
@@ -63,6 +78,9 @@ type ContentDatabaseSourceFieldRowDb =
63
78
  typeof schema.contentDatabaseSourceFields.$inferSelect;
64
79
  type ContentDatabaseSourceRecordRowDb =
65
80
  typeof schema.contentDatabaseSourceRows.$inferSelect;
81
+ type ContentDatabaseItemRowDb = typeof schema.contentDatabaseItems.$inferSelect;
82
+ type ContentDatabaseBodyHydrationQueueRowDb =
83
+ typeof schema.contentDatabaseBodyHydrationQueue.$inferSelect;
66
84
  type ContentDatabaseSourceChangeSetRowDb =
67
85
  typeof schema.contentDatabaseSourceChangeSets.$inferSelect;
68
86
  type ContentDatabaseSourceChangeReviewRowDb =
@@ -446,6 +464,836 @@ function sameSourceFieldValue(a: unknown, b: unknown): boolean {
446
464
  return normalize(a) === normalize(b);
447
465
  }
448
466
 
467
+ function stringSourceValue(
468
+ values: Record<string, DocumentPropertyValue>,
469
+ key: string,
470
+ ) {
471
+ const value = values[key];
472
+ return typeof value === "string" ? value : null;
473
+ }
474
+
475
+ function bodyExcerpt(value: string | null | undefined) {
476
+ const excerpt = value?.trim().slice(0, 140) ?? "";
477
+ return excerpt || null;
478
+ }
479
+
480
+ const BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY = 10;
481
+ const BUILDER_BODY_HYDRATION_OPEN_PRIORITY = 0;
482
+ const BUILDER_BODY_HYDRATION_BATCH_LIMIT = 8;
483
+ const BUILDER_BODY_HYDRATION_CODEC_VERSION = "readable-native-images-v4";
484
+
485
+ async function builderBodySnapshotForEntry(entry: BuilderCmsSourceEntry) {
486
+ if (!entry.rawEntry) return null;
487
+ const [readableBundle, losslessBundle] = await Promise.all([
488
+ builderEntryToReadableMdxBundle(entry.rawEntry),
489
+ builderEntryToMdxBundle(entry.rawEntry),
490
+ ]);
491
+ const sidecars: Record<string, string> = {};
492
+ for (const [path, source] of Object.entries(losslessBundle.files)) {
493
+ if (path !== losslessBundle.mdx.path) sidecars[path] = source;
494
+ }
495
+ return {
496
+ content: readableBundle.mdx.body,
497
+ losslessContent: losslessBundle.mdx.body,
498
+ blocksHash: builderBlocksHash(losslessBundle.blocks),
499
+ readableMapJson: null,
500
+ sidecarsJson: JSON.stringify(sidecars),
501
+ };
502
+ }
503
+
504
+ export function builderBodyHydrationVersion(entry: BuilderCmsSourceEntry) {
505
+ const hash = stringSourceValue(
506
+ entry.sourceValues,
507
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
508
+ );
509
+ return `${hash ?? entry.updatedAt ?? entry.id}:${BUILDER_BODY_HYDRATION_CODEC_VERSION}`;
510
+ }
511
+
512
+ function normalizeHydrationLimit(limit: number | null | undefined) {
513
+ if (typeof limit !== "number" || !Number.isFinite(limit)) {
514
+ return BUILDER_BODY_HYDRATION_BATCH_LIMIT;
515
+ }
516
+ return Math.max(1, Math.min(Math.floor(limit), 50));
517
+ }
518
+
519
+ function builderBodyIsRawPlaceholderOnly(content: string | null | undefined) {
520
+ const trimmed = content?.trim() ?? "";
521
+ return (
522
+ trimmed.startsWith("<BuilderRawBlock") &&
523
+ !trimmed.includes("<BuilderText") &&
524
+ !trimmed.includes("<BuilderTabbedContent") &&
525
+ !trimmed.includes("<BuilderCodeBlock")
526
+ );
527
+ }
528
+
529
+ function builderBodyHasLegacyPreservedComponentPlaceholders(
530
+ content: string | null | undefined,
531
+ ) {
532
+ const value = content ?? "";
533
+ return /^>\s*Builder .+ component preserved from source\.$/m.test(value);
534
+ }
535
+
536
+ function builderBodyWithoutSourceComponentMarkers(
537
+ content: string | null | undefined,
538
+ ) {
539
+ return (content ?? "")
540
+ .replace(/(?:^|\n)<SourceComponent\b[\s\S]*?\/>[ \t]*(?=\n|$)/g, "\n")
541
+ .replace(/\n{3,}/g, "\n\n")
542
+ .trim();
543
+ }
544
+
545
+ function builderBodyWithoutImageSourceComponentMarkers(
546
+ content: string | null | undefined,
547
+ ) {
548
+ return (content ?? "")
549
+ .replace(/(?:^|\n)<SourceComponent\b[\s\S]*?\/>[ \t]*(?=\n|$)/g, (marker) =>
550
+ marker.includes('componentName="Image"') ? "\n" : marker,
551
+ )
552
+ .replace(/\n{3,}/g, "\n\n")
553
+ .trim();
554
+ }
555
+
556
+ function builderBodyWithoutMarkdownImages(content: string | null | undefined) {
557
+ return (content ?? "")
558
+ .replace(
559
+ /(?:^|\n)!\[(?:\\.|[^\]\\])*\]\(\S+?(?:\s+"[^"]*")?\)[ \t]*(?=\n|$)/g,
560
+ "\n",
561
+ )
562
+ .replace(/\n{3,}/g, "\n\n")
563
+ .trim();
564
+ }
565
+
566
+ function normalizedBuilderBodyProse(content: string | null | undefined) {
567
+ return (content ?? "")
568
+ .normalize("NFKC")
569
+ .replace(/\u00a0/g, " ")
570
+ .replace(/\s+/g, " ")
571
+ .trim();
572
+ }
573
+
574
+ export function builderBodyNeedsSourceComponentWrite(args: {
575
+ currentContent: string | null | undefined;
576
+ nextContent: string | null | undefined;
577
+ }) {
578
+ const nextContent = args.nextContent ?? "";
579
+ const currentContent = args.currentContent ?? "";
580
+ const needsSourceComponentRefresh =
581
+ nextContent.includes("<SourceComponent") &&
582
+ normalizedBuilderBodyProse(
583
+ builderBodyWithoutSourceComponentMarkers(nextContent),
584
+ ) ===
585
+ normalizedBuilderBodyProse(
586
+ builderBodyWithoutSourceComponentMarkers(currentContent),
587
+ );
588
+ if (needsSourceComponentRefresh) return true;
589
+ if (
590
+ !currentContent.includes('componentName="Image"') ||
591
+ !nextContent.includes("![")
592
+ ) {
593
+ return false;
594
+ }
595
+ return (
596
+ normalizedBuilderBodyProse(
597
+ builderBodyWithoutMarkdownImages(nextContent),
598
+ ) ===
599
+ normalizedBuilderBodyProse(
600
+ builderBodyWithoutImageSourceComponentMarkers(currentContent),
601
+ )
602
+ );
603
+ }
604
+
605
+ function builderStoredBodyIsStale(args: {
606
+ item: Pick<
607
+ ContentDatabaseItemRowDb,
608
+ "bodyHydrationStatus" | "bodyHydrationVersion"
609
+ >;
610
+ entry: BuilderCmsSourceEntry;
611
+ }) {
612
+ return (
613
+ args.item.bodyHydrationStatus !== "hydrated" ||
614
+ args.item.bodyHydrationVersion !== builderBodyHydrationVersion(args.entry)
615
+ );
616
+ }
617
+
618
+ async function readableBuilderBodyFromStoredLossless(args: {
619
+ losslessContent: string | null;
620
+ sidecarsJson: string | null;
621
+ }) {
622
+ if (!args.losslessContent?.trim()) return null;
623
+ const sidecars =
624
+ parseObject<Record<string, string>>(args.sidecarsJson ?? "{}") ?? {};
625
+ const blocks = await builderMdxBodyToBuilderBlocks(
626
+ args.losslessContent,
627
+ sidecars,
628
+ );
629
+ if (blocks.length === 0) return null;
630
+ return builderBlocksToReadableMarkdown(blocks);
631
+ }
632
+
633
+ async function refreshBuilderBodySourceValuesFromStoredLossless(
634
+ entry: BuilderCmsSourceEntry,
635
+ ) {
636
+ const losslessContent = stringSourceValue(
637
+ entry.sourceValues,
638
+ BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
639
+ );
640
+ const sidecarsJson = stringSourceValue(
641
+ entry.sourceValues,
642
+ BUILDER_CMS_BODY_SIDECARS_KEY,
643
+ );
644
+ const content = await readableBuilderBodyFromStoredLossless({
645
+ losslessContent,
646
+ sidecarsJson,
647
+ });
648
+ if (!content) return entry;
649
+ return {
650
+ ...entry,
651
+ sourceValues: {
652
+ ...entry.sourceValues,
653
+ [BUILDER_CMS_BODY_CONTENT_KEY]: content,
654
+ },
655
+ };
656
+ }
657
+
658
+ function builderEntryFromSourceRow(args: {
659
+ row: Pick<
660
+ ContentDatabaseSourceRecordRowDb,
661
+ "sourceRowId" | "sourceValuesJson" | "lastSourceUpdatedAt"
662
+ >;
663
+ sourceTable: string;
664
+ fallbackTitle: string;
665
+ }): BuilderCmsSourceEntry | null {
666
+ const sourceValues =
667
+ parseObject<Record<string, DocumentPropertyValue>>(
668
+ args.row.sourceValuesJson,
669
+ ) ?? {};
670
+ const id = args.row.sourceRowId;
671
+ if (!id) return null;
672
+ return {
673
+ id,
674
+ model: args.sourceTable,
675
+ title:
676
+ stringSourceValue(sourceValues, "data.title") ??
677
+ stringSourceValue(sourceValues, "title") ??
678
+ args.fallbackTitle,
679
+ urlPath:
680
+ stringSourceValue(sourceValues, "data.url") ??
681
+ stringSourceValue(sourceValues, "url") ??
682
+ "",
683
+ updatedAt:
684
+ args.row.lastSourceUpdatedAt ??
685
+ stringSourceValue(sourceValues, "lastUpdated") ??
686
+ id,
687
+ sourceValues,
688
+ };
689
+ }
690
+
691
+ export async function enqueueBuilderBodyHydration(args: {
692
+ sourceId: string;
693
+ ownerEmail: string;
694
+ orgId: string | null;
695
+ databaseItemId: string;
696
+ documentId: string;
697
+ sourceTable: string;
698
+ entry: BuilderCmsSourceEntry;
699
+ now: string;
700
+ priority?: number;
701
+ }) {
702
+ const db = getDb();
703
+ const sourceRowId = args.entry.id;
704
+ const priority = args.priority ?? BUILDER_BODY_HYDRATION_BACKGROUND_PRIORITY;
705
+ const [existing] = await db
706
+ .select()
707
+ .from(schema.contentDatabaseBodyHydrationQueue)
708
+ .where(
709
+ eq(
710
+ schema.contentDatabaseBodyHydrationQueue.databaseItemId,
711
+ args.databaseItemId,
712
+ ),
713
+ );
714
+ const values = {
715
+ ownerEmail: args.ownerEmail,
716
+ orgId: args.orgId,
717
+ sourceId: args.sourceId,
718
+ databaseItemId: args.databaseItemId,
719
+ documentId: args.documentId,
720
+ sourceRowId,
721
+ sourceTable: args.sourceTable,
722
+ sourceEntryJson: JSON.stringify(args.entry),
723
+ priority: Math.min(existing?.priority ?? priority, priority),
724
+ lastError: null,
725
+ updatedAt: args.now,
726
+ };
727
+ if (existing) {
728
+ await db
729
+ .update(schema.contentDatabaseBodyHydrationQueue)
730
+ .set(values)
731
+ .where(eq(schema.contentDatabaseBodyHydrationQueue.id, existing.id));
732
+ } else {
733
+ await db.insert(schema.contentDatabaseBodyHydrationQueue).values({
734
+ id: crypto.randomUUID(),
735
+ ...values,
736
+ attempts: 0,
737
+ lastAttemptedAt: null,
738
+ createdAt: args.now,
739
+ });
740
+ }
741
+ await db
742
+ .update(schema.contentDatabaseItems)
743
+ .set({
744
+ bodyHydrationStatus: "pending",
745
+ bodyHydrationError: null,
746
+ updatedAt: args.now,
747
+ })
748
+ .where(eq(schema.contentDatabaseItems.id, args.databaseItemId));
749
+ }
750
+
751
+ export async function enqueueBuilderBodyHydrationForItems(args: {
752
+ sourceId: string;
753
+ ownerEmail: string;
754
+ orgId: string | null;
755
+ sourceTable: string;
756
+ items: ContentDatabaseItem[];
757
+ builderEntriesByDocumentId: Map<string, BuilderCmsSourceEntry> | undefined;
758
+ now: string;
759
+ }) {
760
+ if (!args.builderEntriesByDocumentId?.size) return;
761
+ for (const item of args.items) {
762
+ const entry = args.builderEntriesByDocumentId.get(item.document.id);
763
+ if (!entry?.rawEntry) continue;
764
+ if (
765
+ item.bodyHydration?.status === "hydrated" &&
766
+ item.bodyHydration.version === builderBodyHydrationVersion(entry) &&
767
+ !builderBodyIsRawPlaceholderOnly(item.document.content)
768
+ ) {
769
+ continue;
770
+ }
771
+ await enqueueBuilderBodyHydration({
772
+ sourceId: args.sourceId,
773
+ ownerEmail: args.ownerEmail,
774
+ orgId: args.orgId,
775
+ databaseItemId: item.id,
776
+ documentId: item.document.id,
777
+ sourceTable: args.sourceTable,
778
+ entry,
779
+ now: args.now,
780
+ });
781
+ }
782
+ void processBuilderBodyHydrationQueue({
783
+ sourceId: args.sourceId,
784
+ limit: BUILDER_BODY_HYDRATION_BATCH_LIMIT,
785
+ }).catch(() => undefined);
786
+ }
787
+
788
+ function parseHydrationEntry(
789
+ row: ContentDatabaseBodyHydrationQueueRowDb,
790
+ ): BuilderCmsSourceEntry | null {
791
+ const parsed = parseObject<BuilderCmsSourceEntry>(row.sourceEntryJson);
792
+ return parsed?.id ? parsed : null;
793
+ }
794
+
795
+ async function processBuilderBodyHydrationJob(
796
+ row: ContentDatabaseBodyHydrationQueueRowDb,
797
+ now: string,
798
+ ) {
799
+ const db = getDb();
800
+ const [claimed] = await db
801
+ .update(schema.contentDatabaseBodyHydrationQueue)
802
+ .set({
803
+ attempts: row.attempts + 1,
804
+ lastAttemptedAt: now,
805
+ lastError: null,
806
+ updatedAt: now,
807
+ })
808
+ .where(
809
+ and(
810
+ eq(schema.contentDatabaseBodyHydrationQueue.id, row.id),
811
+ eq(
812
+ schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
813
+ row.sourceEntryJson,
814
+ ),
815
+ ),
816
+ )
817
+ .returning({ id: schema.contentDatabaseBodyHydrationQueue.id });
818
+ if (!claimed) return;
819
+
820
+ await db
821
+ .update(schema.contentDatabaseItems)
822
+ .set({
823
+ bodyHydrationStatus: "hydrating",
824
+ bodyHydrationAttemptedAt: now,
825
+ bodyHydrationError: null,
826
+ updatedAt: now,
827
+ })
828
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
829
+
830
+ const entry = parseHydrationEntry(row);
831
+ if (!entry) throw new Error("Builder body hydration entry is missing.");
832
+ const entryWithBody = await refreshBuilderBodySourceValuesFromStoredLossless(
833
+ await withBuilderBodySourceValues(entry),
834
+ );
835
+ const sourceValues =
836
+ parseObject<Record<string, DocumentPropertyValue>>(
837
+ (
838
+ await db
839
+ .select({
840
+ sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
841
+ })
842
+ .from(schema.contentDatabaseSourceRows)
843
+ .where(
844
+ and(
845
+ eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId),
846
+ eq(
847
+ schema.contentDatabaseSourceRows.databaseItemId,
848
+ row.databaseItemId,
849
+ ),
850
+ ),
851
+ )
852
+ )[0]?.sourceValuesJson ?? "{}",
853
+ ) ?? {};
854
+ const nextValues = {
855
+ ...sourceValues,
856
+ ...entryWithBody.sourceValues,
857
+ };
858
+ const nextContent =
859
+ stringSourceValue(nextValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? "";
860
+ const [document] = await db
861
+ .select({ content: schema.documents.content })
862
+ .from(schema.documents)
863
+ .where(eq(schema.documents.id, row.documentId));
864
+ const previousContent =
865
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_CONTENT_KEY) ?? "";
866
+ const currentContent = document?.content ?? "";
867
+ const shouldWriteBody =
868
+ currentContent === "" ||
869
+ currentContent === previousContent ||
870
+ currentContent.trim() === "" ||
871
+ builderBodyIsRawPlaceholderOnly(currentContent) ||
872
+ builderBodyHasLegacyPreservedComponentPlaceholders(currentContent) ||
873
+ builderBodyNeedsSourceComponentWrite({
874
+ currentContent,
875
+ nextContent,
876
+ });
877
+ let wroteBody = false;
878
+ await db.transaction(async (tx) => {
879
+ if (shouldWriteBody) {
880
+ const [updatedDocument] = await tx
881
+ .update(schema.documents)
882
+ .set({ content: nextContent, updatedAt: now })
883
+ .where(
884
+ and(
885
+ eq(schema.documents.id, row.documentId),
886
+ eq(schema.documents.content, currentContent),
887
+ ),
888
+ )
889
+ .returning({ id: schema.documents.id });
890
+ wroteBody = Boolean(updatedDocument);
891
+ }
892
+ if (shouldWriteBody && !wroteBody) {
893
+ await tx
894
+ .update(schema.contentDatabaseItems)
895
+ .set({
896
+ bodyHydrationStatus: "pending",
897
+ bodyHydrationAttemptedAt: now,
898
+ bodyHydrationError:
899
+ "Skipped Builder body hydration because the document changed during sync.",
900
+ updatedAt: now,
901
+ })
902
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
903
+ await tx
904
+ .update(schema.contentDatabaseBodyHydrationQueue)
905
+ .set({
906
+ lastError:
907
+ "Skipped Builder body hydration because the document changed during sync.",
908
+ updatedAt: now,
909
+ })
910
+ .where(
911
+ and(
912
+ eq(schema.contentDatabaseBodyHydrationQueue.id, row.id),
913
+ eq(
914
+ schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
915
+ row.sourceEntryJson,
916
+ ),
917
+ ),
918
+ );
919
+ return;
920
+ }
921
+ const [deleted] = await tx
922
+ .delete(schema.contentDatabaseBodyHydrationQueue)
923
+ .where(
924
+ and(
925
+ eq(schema.contentDatabaseBodyHydrationQueue.id, row.id),
926
+ eq(
927
+ schema.contentDatabaseBodyHydrationQueue.sourceEntryJson,
928
+ row.sourceEntryJson,
929
+ ),
930
+ ),
931
+ )
932
+ .returning({ id: schema.contentDatabaseBodyHydrationQueue.id });
933
+ if (!deleted) {
934
+ if (wroteBody) {
935
+ await tx
936
+ .update(schema.contentDatabaseSourceRows)
937
+ .set({
938
+ sourceValuesJson: JSON.stringify(nextValues),
939
+ lastSyncedAt: now,
940
+ lastSourceUpdatedAt: entry.updatedAt ?? now,
941
+ updatedAt: now,
942
+ })
943
+ .where(
944
+ and(
945
+ eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId),
946
+ eq(
947
+ schema.contentDatabaseSourceRows.databaseItemId,
948
+ row.databaseItemId,
949
+ ),
950
+ ),
951
+ );
952
+ }
953
+ await tx
954
+ .update(schema.contentDatabaseItems)
955
+ .set({
956
+ bodyHydrationStatus: "pending",
957
+ bodyHydrationAttemptedAt: now,
958
+ bodyHydrationError: null,
959
+ updatedAt: now,
960
+ })
961
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
962
+ return;
963
+ }
964
+ await tx
965
+ .update(schema.contentDatabaseSourceRows)
966
+ .set({
967
+ sourceValuesJson: JSON.stringify(nextValues),
968
+ lastSyncedAt: now,
969
+ lastSourceUpdatedAt: entry.updatedAt ?? now,
970
+ updatedAt: now,
971
+ })
972
+ .where(
973
+ and(
974
+ eq(schema.contentDatabaseSourceRows.sourceId, row.sourceId),
975
+ eq(
976
+ schema.contentDatabaseSourceRows.databaseItemId,
977
+ row.databaseItemId,
978
+ ),
979
+ ),
980
+ );
981
+ await tx
982
+ .update(schema.contentDatabaseItems)
983
+ .set({
984
+ bodyHydrationStatus: "hydrated",
985
+ bodyHydrationAttemptedAt: now,
986
+ bodyHydrationError: null,
987
+ bodyHydrationVersion: builderBodyHydrationVersion(entryWithBody),
988
+ updatedAt: now,
989
+ })
990
+ .where(eq(schema.contentDatabaseItems.id, row.databaseItemId));
991
+ });
992
+ if (wroteBody) {
993
+ try {
994
+ await deleteCollabState(row.documentId);
995
+ releaseDoc(row.documentId);
996
+ } catch {
997
+ // Non-fatal: the updated document row still lets open editors reconcile.
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ async function enqueueStaleBuilderBodyHydrationForOpenDocument(args: {
1003
+ sourceId: string;
1004
+ documentId: string;
1005
+ now: string;
1006
+ }) {
1007
+ const db = getDb();
1008
+ const [row] = await db
1009
+ .select({
1010
+ source: schema.contentDatabaseSources,
1011
+ item: schema.contentDatabaseItems,
1012
+ sourceRow: schema.contentDatabaseSourceRows,
1013
+ document: schema.documents,
1014
+ })
1015
+ .from(schema.contentDatabaseSourceRows)
1016
+ .innerJoin(
1017
+ schema.contentDatabaseSources,
1018
+ eq(
1019
+ schema.contentDatabaseSources.id,
1020
+ schema.contentDatabaseSourceRows.sourceId,
1021
+ ),
1022
+ )
1023
+ .innerJoin(
1024
+ schema.contentDatabaseItems,
1025
+ eq(
1026
+ schema.contentDatabaseItems.id,
1027
+ schema.contentDatabaseSourceRows.databaseItemId,
1028
+ ),
1029
+ )
1030
+ .innerJoin(
1031
+ schema.documents,
1032
+ eq(schema.documents.id, schema.contentDatabaseSourceRows.documentId),
1033
+ )
1034
+ .where(
1035
+ and(
1036
+ eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId),
1037
+ eq(schema.contentDatabaseSourceRows.documentId, args.documentId),
1038
+ ),
1039
+ );
1040
+ if (!row || row.source.sourceType !== "builder-cms") return;
1041
+ const entry = builderEntryFromSourceRow({
1042
+ row: row.sourceRow,
1043
+ sourceTable: row.source.sourceTable,
1044
+ fallbackTitle: row.document.title,
1045
+ });
1046
+ if (!entry) return;
1047
+ const refreshedEntry =
1048
+ await refreshBuilderBodySourceValuesFromStoredLossless(entry);
1049
+ if (
1050
+ !builderStoredBodyIsStale({ item: row.item, entry: refreshedEntry }) &&
1051
+ !builderBodyHasLegacyPreservedComponentPlaceholders(row.document.content) &&
1052
+ !builderBodyNeedsSourceComponentWrite({
1053
+ currentContent: row.document.content,
1054
+ nextContent: stringSourceValue(
1055
+ refreshedEntry.sourceValues,
1056
+ BUILDER_CMS_BODY_CONTENT_KEY,
1057
+ ),
1058
+ })
1059
+ ) {
1060
+ return;
1061
+ }
1062
+ await enqueueBuilderBodyHydration({
1063
+ sourceId: args.sourceId,
1064
+ ownerEmail: row.item.ownerEmail,
1065
+ orgId: row.item.orgId,
1066
+ databaseItemId: row.item.id,
1067
+ documentId: row.item.documentId,
1068
+ sourceTable: row.source.sourceTable,
1069
+ entry: refreshedEntry,
1070
+ now: args.now,
1071
+ priority: BUILDER_BODY_HYDRATION_OPEN_PRIORITY,
1072
+ });
1073
+ }
1074
+
1075
+ export async function processBuilderBodyHydrationQueue(args: {
1076
+ sourceId: string;
1077
+ documentId?: string | null;
1078
+ limit?: number | null;
1079
+ }) {
1080
+ const db = getDb();
1081
+ const limit = normalizeHydrationLimit(args.limit);
1082
+ const now = new Date().toISOString();
1083
+ if (args.documentId) {
1084
+ await enqueueStaleBuilderBodyHydrationForOpenDocument({
1085
+ sourceId: args.sourceId,
1086
+ documentId: args.documentId,
1087
+ now,
1088
+ });
1089
+ await db
1090
+ .update(schema.contentDatabaseBodyHydrationQueue)
1091
+ .set({
1092
+ priority: BUILDER_BODY_HYDRATION_OPEN_PRIORITY,
1093
+ updatedAt: now,
1094
+ })
1095
+ .where(
1096
+ and(
1097
+ eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.sourceId),
1098
+ eq(
1099
+ schema.contentDatabaseBodyHydrationQueue.documentId,
1100
+ args.documentId,
1101
+ ),
1102
+ ),
1103
+ );
1104
+ }
1105
+ const jobs = await db
1106
+ .select()
1107
+ .from(schema.contentDatabaseBodyHydrationQueue)
1108
+ .where(
1109
+ args.documentId
1110
+ ? and(
1111
+ eq(
1112
+ schema.contentDatabaseBodyHydrationQueue.sourceId,
1113
+ args.sourceId,
1114
+ ),
1115
+ eq(
1116
+ schema.contentDatabaseBodyHydrationQueue.documentId,
1117
+ args.documentId,
1118
+ ),
1119
+ )
1120
+ : eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.sourceId),
1121
+ )
1122
+ .orderBy(
1123
+ asc(schema.contentDatabaseBodyHydrationQueue.priority),
1124
+ asc(schema.contentDatabaseBodyHydrationQueue.createdAt),
1125
+ )
1126
+ .limit(limit);
1127
+
1128
+ let succeeded = 0;
1129
+ let failed = 0;
1130
+ for (const job of jobs) {
1131
+ const attemptNow = new Date().toISOString();
1132
+ try {
1133
+ await processBuilderBodyHydrationJob(job, attemptNow);
1134
+ succeeded += 1;
1135
+ } catch (error) {
1136
+ failed += 1;
1137
+ const message = error instanceof Error ? error.message : String(error);
1138
+ await db
1139
+ .update(schema.contentDatabaseItems)
1140
+ .set({
1141
+ bodyHydrationStatus: "error",
1142
+ bodyHydrationAttemptedAt: attemptNow,
1143
+ bodyHydrationError: message,
1144
+ updatedAt: attemptNow,
1145
+ })
1146
+ .where(eq(schema.contentDatabaseItems.id, job.databaseItemId));
1147
+ await db
1148
+ .update(schema.contentDatabaseBodyHydrationQueue)
1149
+ .set({
1150
+ attempts: job.attempts + 1,
1151
+ lastAttemptedAt: attemptNow,
1152
+ lastError: message,
1153
+ priority: job.priority + 10,
1154
+ updatedAt: attemptNow,
1155
+ })
1156
+ .where(eq(schema.contentDatabaseBodyHydrationQueue.id, job.id));
1157
+ }
1158
+ }
1159
+ const [remaining] = await db
1160
+ .select({ count: sql<number>`COUNT(*)` })
1161
+ .from(schema.contentDatabaseBodyHydrationQueue)
1162
+ .where(
1163
+ eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.sourceId),
1164
+ );
1165
+ return {
1166
+ sourceId: args.sourceId,
1167
+ processed: jobs.length,
1168
+ succeeded,
1169
+ failed,
1170
+ remaining: Number(remaining?.count ?? 0),
1171
+ };
1172
+ }
1173
+
1174
+ export async function withBuilderBodySourceValues(
1175
+ entry: BuilderCmsSourceEntry,
1176
+ ): Promise<BuilderCmsSourceEntry> {
1177
+ const snapshot = await builderBodySnapshotForEntry(entry);
1178
+ if (!snapshot) return entry;
1179
+ return {
1180
+ ...entry,
1181
+ sourceValues: {
1182
+ ...entry.sourceValues,
1183
+ [BUILDER_CMS_BODY_CONTENT_KEY]: snapshot.content,
1184
+ [BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY]: snapshot.losslessContent,
1185
+ [BUILDER_CMS_BODY_READABLE_MAP_KEY]: snapshot.readableMapJson,
1186
+ [BUILDER_CMS_BODY_BLOCKS_HASH_KEY]: snapshot.blocksHash,
1187
+ [BUILDER_CMS_BODY_SIDECARS_KEY]: snapshot.sidecarsJson,
1188
+ },
1189
+ };
1190
+ }
1191
+
1192
+ export async function withBuilderBodiesSourceValues(
1193
+ entries: BuilderCmsSourceEntry[],
1194
+ ) {
1195
+ return Promise.all(
1196
+ entries.map((entry) => withBuilderBodySourceValues(entry)),
1197
+ );
1198
+ }
1199
+
1200
+ async function builderBodyChangeForLocalContent(args: {
1201
+ row: Pick<ContentDatabaseSourceRecordRowDb, "sourceValuesJson">;
1202
+ localContent: string | null | undefined;
1203
+ }): Promise<ContentDatabaseSourceBodyChange | null> {
1204
+ const sourceValues =
1205
+ parseObject<Record<string, DocumentPropertyValue>>(
1206
+ args.row.sourceValuesJson,
1207
+ ) ?? {};
1208
+ const currentHash = stringSourceValue(
1209
+ sourceValues,
1210
+ BUILDER_CMS_BODY_BLOCKS_HASH_KEY,
1211
+ );
1212
+ const currentContent = stringSourceValue(
1213
+ sourceValues,
1214
+ BUILDER_CMS_BODY_CONTENT_KEY,
1215
+ );
1216
+ const losslessContent = stringSourceValue(
1217
+ sourceValues,
1218
+ BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY,
1219
+ );
1220
+ const sidecarsJson =
1221
+ stringSourceValue(sourceValues, BUILDER_CMS_BODY_SIDECARS_KEY) ?? "{}";
1222
+ const localContent = args.localContent ?? "";
1223
+ if (!currentHash && !currentContent && !localContent.trim()) return null;
1224
+
1225
+ let sidecars: Record<string, string> = {};
1226
+ try {
1227
+ const parsed = JSON.parse(sidecarsJson) as unknown;
1228
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1229
+ sidecars = Object.fromEntries(
1230
+ Object.entries(parsed as Record<string, unknown>).filter(
1231
+ (entry): entry is [string, string] => typeof entry[1] === "string",
1232
+ ),
1233
+ );
1234
+ }
1235
+ } catch {
1236
+ sidecars = {};
1237
+ }
1238
+
1239
+ try {
1240
+ const proposed =
1241
+ losslessContent && !localContent.includes("<Builder")
1242
+ ? await builderReadableBodyToBuilderBlocks({
1243
+ localContent,
1244
+ losslessContent,
1245
+ sidecars,
1246
+ })
1247
+ : {
1248
+ blocks: await builderMdxBodyToBuilderBlocks(localContent, sidecars),
1249
+ warnings: [] as string[],
1250
+ };
1251
+ if (!proposed.blocks) {
1252
+ return {
1253
+ summary: "Builder body blocks changed, but need attention before push.",
1254
+ currentExcerpt: bodyExcerpt(currentContent),
1255
+ proposedExcerpt: bodyExcerpt(localContent),
1256
+ currentHash,
1257
+ proposedHash: null,
1258
+ proposedContent: localContent,
1259
+ proposedBlocksJson: null,
1260
+ sidecarsJson,
1261
+ warnings: proposed.warnings,
1262
+ };
1263
+ }
1264
+ const proposedBlocks = proposed.blocks;
1265
+ const proposedHash = builderBlocksHash(proposedBlocks);
1266
+ if (currentHash && proposedHash === currentHash) return null;
1267
+ return {
1268
+ summary: "Builder body blocks changed.",
1269
+ currentExcerpt: bodyExcerpt(currentContent),
1270
+ proposedExcerpt: bodyExcerpt(localContent),
1271
+ currentHash,
1272
+ proposedHash,
1273
+ proposedContent: localContent,
1274
+ proposedBlocksJson: JSON.stringify(proposedBlocks),
1275
+ sidecarsJson,
1276
+ warnings: proposed.warnings,
1277
+ };
1278
+ } catch (error) {
1279
+ const message =
1280
+ error instanceof Error
1281
+ ? error.message
1282
+ : "Builder body could not be converted.";
1283
+ return {
1284
+ summary: "Builder body blocks changed, but need attention before push.",
1285
+ currentExcerpt: bodyExcerpt(currentContent),
1286
+ proposedExcerpt: bodyExcerpt(localContent),
1287
+ currentHash,
1288
+ proposedHash: null,
1289
+ proposedContent: localContent,
1290
+ proposedBlocksJson: null,
1291
+ sidecarsJson,
1292
+ warnings: [message],
1293
+ };
1294
+ }
1295
+ }
1296
+
449
1297
  export function buildBuilderLocalOutboundChangeSets(args: {
450
1298
  source: ContentDatabaseSourceRowDb;
451
1299
  rowRows: ContentDatabaseSourceRecordRowDb[];
@@ -473,6 +1321,7 @@ export function buildBuilderLocalOutboundChangeSets(args: {
473
1321
  // collection is adopted as a create_draft by THAT collection only; an
474
1322
  // untagged / "Local" row falls back to the primary (allowUnsourcedCreates).
475
1323
  taggedSourceByDocumentId?: Map<string, string>;
1324
+ bodyChangeByDocumentId?: Map<string, ContentDatabaseSourceBodyChange>;
476
1325
  }): ContentDatabaseSourceChangeSet[] {
477
1326
  if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return [];
478
1327
 
@@ -528,7 +1377,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
528
1377
  });
529
1378
  }
530
1379
  }
531
- if (fieldChanges.length === 0) continue;
1380
+ const bodyChange = args.bodyChangeByDocumentId?.get(row.documentId) ?? null;
1381
+ if (fieldChanges.length === 0 && !bodyChange) continue;
532
1382
  // Skip if this row already has a live (non-rejected/applied) stored outbound
533
1383
  // autosave change-set — the stored one is what's being reviewed/pushed.
534
1384
  const matchesStoredChange = args.storedChangeSets.some((changeSet) => {
@@ -541,35 +1391,45 @@ export function buildBuilderLocalOutboundChangeSets(args: {
541
1391
  ) {
542
1392
  return false;
543
1393
  }
544
- return changeSet.fieldChanges.some((stored) =>
545
- fieldChanges.some(
546
- (change) =>
547
- change.localFieldKey === stored.localFieldKey &&
548
- sameSourceFieldValue(change.currentValue, stored.currentValue) &&
549
- sameSourceFieldValue(change.proposedValue, stored.proposedValue),
550
- ),
1394
+ return (
1395
+ changeSet.fieldChanges.some((stored) =>
1396
+ fieldChanges.some(
1397
+ (change) =>
1398
+ change.localFieldKey === stored.localFieldKey &&
1399
+ sameSourceFieldValue(change.currentValue, stored.currentValue) &&
1400
+ sameSourceFieldValue(change.proposedValue, stored.proposedValue),
1401
+ ),
1402
+ ) ||
1403
+ (!!bodyChange && !!changeSet.bodyChange)
551
1404
  );
552
1405
  });
553
1406
  if (matchesStoredChange) continue;
554
1407
 
555
1408
  const now = new Date().toISOString();
1409
+ const displayTitle = localTitle || sourceTitle;
556
1410
  pending.push({
557
- id: `local-pending-${row.id}-title`,
1411
+ id: `local-pending-${row.id}-${bodyChange ? "body" : "fields"}`,
558
1412
  databaseItemId: row.databaseItemId,
559
1413
  documentId: row.documentId,
560
- kind: "field_update",
1414
+ kind:
1415
+ bodyChange && fieldChanges.length === 0
1416
+ ? "body_update"
1417
+ : "field_update",
561
1418
  direction: "outbound",
562
1419
  state: "pending_push",
563
1420
  pushMode: "autosave",
564
1421
  localOnly: true,
565
1422
  summary:
566
- fieldChanges.length === 1 && fieldChanges[0]?.localFieldKey === "title"
567
- ? `Pending local Builder CMS title change for "${localTitle}".`
568
- : `Pending local Builder CMS changes for "${localTitle || sourceTitle}".`,
1423
+ bodyChange && fieldChanges.length === 0
1424
+ ? `Pending local Builder CMS body change for "${displayTitle}".`
1425
+ : fieldChanges.length === 1 &&
1426
+ fieldChanges[0]?.localFieldKey === "title"
1427
+ ? `Pending local Builder CMS title change for "${localTitle}".`
1428
+ : `Pending local Builder CMS changes for "${displayTitle}".`,
569
1429
  fieldChanges,
570
- bodyChange: null,
1430
+ bodyChange,
571
1431
  riskLevel: "low",
572
- riskReasons: ["single field diff"],
1432
+ riskReasons: bodyChange ? ["body diff"] : ["single field diff"],
573
1433
  conflictState: "none",
574
1434
  reviewEvents: [],
575
1435
  executions: [],
@@ -617,6 +1477,8 @@ export function buildBuilderLocalOutboundChangeSets(args: {
617
1477
  const title = args.documentTitleById.get(item.documentId)?.trim() ?? "";
618
1478
  if (!title) continue;
619
1479
  const localValues = args.localValuesByDocument?.get(item.documentId);
1480
+ const bodyChange =
1481
+ args.bodyChangeByDocumentId?.get(item.documentId) ?? null;
620
1482
  const fieldChanges: ContentDatabaseSourceFieldChange[] = [
621
1483
  {
622
1484
  propertyId: null,
@@ -651,9 +1513,11 @@ export function buildBuilderLocalOutboundChangeSets(args: {
651
1513
  localOnly: true,
652
1514
  summary: `Pending new Builder entry "${title}".`,
653
1515
  fieldChanges,
654
- bodyChange: null,
1516
+ bodyChange,
655
1517
  riskLevel: "low",
656
- riskReasons: ["new Builder entry (create as draft)"],
1518
+ riskReasons: bodyChange
1519
+ ? ["new Builder entry (create as draft)", "body diff"]
1520
+ : ["new Builder entry (create as draft)"],
657
1521
  conflictState: "none",
658
1522
  reviewEvents: [],
659
1523
  executions: [],
@@ -867,6 +1731,7 @@ async function loadSourceSnapshot(
867
1731
  .select({
868
1732
  id: schema.contentDatabaseItems.id,
869
1733
  documentId: schema.contentDatabaseItems.documentId,
1734
+ bodyHydrationStatus: schema.contentDatabaseItems.bodyHydrationStatus,
870
1735
  })
871
1736
  .from(schema.contentDatabaseItems)
872
1737
  .where(
@@ -888,6 +1753,7 @@ async function loadSourceSnapshot(
888
1753
  .select({
889
1754
  id: schema.documents.id,
890
1755
  title: schema.documents.title,
1756
+ content: schema.documents.content,
891
1757
  })
892
1758
  .from(schema.documents)
893
1759
  .where(
@@ -900,6 +1766,9 @@ async function loadSourceSnapshot(
900
1766
  const documentTitleById = new Map(
901
1767
  rowDocuments.map((document) => [document.id, document.title]),
902
1768
  );
1769
+ const documentContentById = new Map(
1770
+ rowDocuments.map((document) => [document.id, document.content]),
1771
+ );
903
1772
  const propertyValueRows =
904
1773
  isBuilderSource && allDocumentIds.length > 0
905
1774
  ? await db
@@ -1003,6 +1872,34 @@ async function loadSourceSnapshot(
1003
1872
  }
1004
1873
  }
1005
1874
  }
1875
+ const bodyChangeByDocumentId = new Map<
1876
+ string,
1877
+ ContentDatabaseSourceBodyChange
1878
+ >();
1879
+ if (isBuilderSource) {
1880
+ const sourceRowByDocumentId = new Map(
1881
+ rowRows.map((row) => [row.documentId, row]),
1882
+ );
1883
+ const hydratedDocumentIds = new Set(
1884
+ databaseItemRows
1885
+ .filter((item) => item.bodyHydrationStatus === "hydrated")
1886
+ .map((item) => item.documentId),
1887
+ );
1888
+ await Promise.all(
1889
+ allDocumentIds.map(async (documentId) => {
1890
+ if (!hydratedDocumentIds.has(documentId)) return;
1891
+ const row = sourceRowByDocumentId.get(documentId) ?? {
1892
+ sourceValuesJson: "{}",
1893
+ };
1894
+ const bodyChange = await builderBodyChangeForLocalContent({
1895
+ row,
1896
+ localContent: documentContentById.get(documentId),
1897
+ });
1898
+ if (bodyChange) bodyChangeByDocumentId.set(documentId, bodyChange);
1899
+ }),
1900
+ );
1901
+ }
1902
+
1006
1903
  const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({
1007
1904
  source,
1008
1905
  rowRows,
@@ -1017,6 +1914,7 @@ async function loadSourceSnapshot(
1017
1914
  otherSourceDocumentIds,
1018
1915
  allowUnsourcedCreates: isPrimarySource,
1019
1916
  taggedSourceByDocumentId,
1917
+ bodyChangeByDocumentId,
1020
1918
  });
1021
1919
  const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row]));
1022
1920
  const changeSets = [...storedChangeSets, ...localOutboundChangeSets].map(
@@ -1052,6 +1950,13 @@ async function loadSourceSnapshot(
1052
1950
  if (target?.title) displaySourceName = target.title;
1053
1951
  }
1054
1952
 
1953
+ const bodyHydration = isBuilderSource
1954
+ ? await sourceBodyHydrationSummary({
1955
+ sourceId: source.id,
1956
+ databaseId: database.id,
1957
+ })
1958
+ : undefined;
1959
+
1055
1960
  return {
1056
1961
  id: source.id,
1057
1962
  databaseId: source.databaseId,
@@ -1097,7 +2002,44 @@ async function loadSourceSnapshot(
1097
2002
  fields,
1098
2003
  rows,
1099
2004
  changeSets,
2005
+ bodyHydration,
2006
+ };
2007
+ }
2008
+
2009
+ async function sourceBodyHydrationSummary(args: {
2010
+ sourceId: string;
2011
+ databaseId: string;
2012
+ }): Promise<ContentDatabaseBodyHydrationSummary> {
2013
+ const rows = await getDb()
2014
+ .select({ status: schema.contentDatabaseItems.bodyHydrationStatus })
2015
+ .from(schema.contentDatabaseItems)
2016
+ .innerJoin(
2017
+ schema.contentDatabaseSourceRows,
2018
+ eq(
2019
+ schema.contentDatabaseSourceRows.databaseItemId,
2020
+ schema.contentDatabaseItems.id,
2021
+ ),
2022
+ )
2023
+ .where(
2024
+ and(
2025
+ eq(schema.contentDatabaseItems.databaseId, args.databaseId),
2026
+ eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId),
2027
+ ),
2028
+ );
2029
+ const summary: ContentDatabaseBodyHydrationSummary = {
2030
+ pending: 0,
2031
+ hydrating: 0,
2032
+ hydrated: 0,
2033
+ error: 0,
2034
+ total: rows.length,
1100
2035
  };
2036
+ for (const row of rows) {
2037
+ if (row.status === "pending") summary.pending += 1;
2038
+ else if (row.status === "hydrating") summary.hydrating += 1;
2039
+ else if (row.status === "error") summary.error += 1;
2040
+ else summary.hydrated += 1;
2041
+ }
2042
+ return summary;
1101
2043
  }
1102
2044
 
1103
2045
  // Pass a stored federation block through only when it has the shape the join
@@ -1714,6 +2656,11 @@ export async function resyncMockSourceSnapshot(args: {
1714
2656
  await db
1715
2657
  .delete(schema.contentDatabaseSourceFields)
1716
2658
  .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id));
2659
+ await db
2660
+ .delete(schema.contentDatabaseBodyHydrationQueue)
2661
+ .where(
2662
+ eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.source.id),
2663
+ );
1717
2664
  await db
1718
2665
  .delete(schema.contentDatabaseSourceRows)
1719
2666
  .where(eq(schema.contentDatabaseSourceRows.sourceId, args.source.id));
@@ -1987,6 +2934,8 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
1987
2934
  databaseId: args.database.id,
1988
2935
  documentId,
1989
2936
  position: nextItemPosition++,
2937
+ bodyHydrationStatus: entry.rawEntry ? "pending" : "hydrated",
2938
+ bodyHydrationError: null,
1990
2939
  createdAt: args.now,
1991
2940
  updatedAt: args.now,
1992
2941
  });
@@ -2006,6 +2955,8 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2006
2955
  const builderRead = await readBuilderCmsContentEntries({
2007
2956
  model: args.source.sourceTable,
2008
2957
  });
2958
+ const builderEntries =
2959
+ builderRead.state === "live" ? builderRead.entries : [];
2009
2960
  let existingRows = await db
2010
2961
  .select()
2011
2962
  .from(schema.contentDatabaseSourceRows)
@@ -2013,7 +2964,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2013
2964
  if (builderRead.state === "live") {
2014
2965
  const imported = await importBuilderCmsEntriesAsDatabaseItems({
2015
2966
  database: args.database,
2016
- entries: builderRead.entries,
2967
+ entries: builderEntries,
2017
2968
  now: args.now,
2018
2969
  sourceTable: args.source.sourceTable,
2019
2970
  existingSourceRows: existingRows,
@@ -2032,7 +2983,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2032
2983
  const builderEntriesByDocumentId =
2033
2984
  builderRead.state === "live"
2034
2985
  ? mapBuilderCmsEntriesToLocalItems({
2035
- entries: builderRead.entries,
2986
+ entries: builderEntries,
2036
2987
  items: response.items,
2037
2988
  sourceTable: args.source.sourceTable,
2038
2989
  now: args.now,
@@ -2052,7 +3003,6 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2052
3003
  },
2053
3004
  ]),
2054
3005
  );
2055
-
2056
3006
  await db
2057
3007
  .delete(schema.contentDatabaseSourceFields)
2058
3008
  .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id));
@@ -2066,8 +3016,7 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2066
3016
  sourceType: "builder-cms",
2067
3017
  properties,
2068
3018
  builderModelFields,
2069
- builderSampleEntries:
2070
- builderRead.state === "live" ? builderRead.entries : [],
3019
+ builderSampleEntries: builderEntries,
2071
3020
  now: args.now,
2072
3021
  });
2073
3022
  // Row-union: a resync must only (re)link items that BELONG to this source —
@@ -2102,6 +3051,17 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
2102
3051
  existingBuilderRows,
2103
3052
  builderEntriesByDocumentId,
2104
3053
  });
3054
+ if (builderRead.state === "live") {
3055
+ await enqueueBuilderBodyHydrationForItems({
3056
+ sourceId: args.source.id,
3057
+ ownerEmail: args.database.ownerEmail,
3058
+ orgId: args.database.orgId,
3059
+ sourceTable: args.source.sourceTable,
3060
+ items: itemsToLink,
3061
+ builderEntriesByDocumentId,
3062
+ now: args.now,
3063
+ });
3064
+ }
2105
3065
 
2106
3066
  const currentDocumentIds = new Set(
2107
3067
  response.items.map((item) => item.document.id),
@@ -2202,6 +3162,11 @@ export async function replaceSourceMetadata(args: {
2202
3162
 
2203
3163
  if (args.source) {
2204
3164
  await deleteSourceChangeSetRecords({ sourceId: args.source.id });
3165
+ await db
3166
+ .delete(schema.contentDatabaseBodyHydrationQueue)
3167
+ .where(
3168
+ eq(schema.contentDatabaseBodyHydrationQueue.sourceId, args.source.id),
3169
+ );
2205
3170
  await db
2206
3171
  .delete(schema.contentDatabaseSourceFields)
2207
3172
  .where(eq(schema.contentDatabaseSourceFields.sourceId, args.source.id));