@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
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4807
31
+ - template files: 4820
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.83.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1a8d939: Add dynamic command menu results and an opt-in contenteditable Cmd+K shortcut path for editor-backed apps.
8
+
3
9
  ## 0.82.0
4
10
 
5
11
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -261,6 +261,8 @@ export interface CommandMenuProps {
261
261
  open: boolean;
262
262
  onOpenChange: (open: boolean) => void;
263
263
  children: ReactNode;
264
+ /** Render app-specific dynamic results from the current search value. */
265
+ renderResults?: (search: string) => ReactNode;
264
266
  /** Placeholder text for the search input */
265
267
  placeholder?: string;
266
268
  /** Text shown when no results match (before showing agent fallback) */
@@ -291,6 +293,7 @@ export function CommandMenu({
291
293
  open,
292
294
  onOpenChange,
293
295
  children,
296
+ renderResults,
294
297
  placeholder = "Type a command or ask AI...",
295
298
  emptyText: _emptyText = "No commands found.",
296
299
  showAgentFallback = true,
@@ -325,8 +328,15 @@ export function CommandMenu({
325
328
  setTimeout(() => setChangelogOpen(true), 50);
326
329
  }, [onOpenChange, markChangelogSeen]);
327
330
 
328
- // Focus input when opening
331
+ // Focus input when opening; clear search while closed so reopen never renders
332
+ // dynamic results for the previous query.
329
333
  useEffect(() => {
334
+ if (!open) {
335
+ setSearch("");
336
+ setSelectedIndex(0);
337
+ return;
338
+ }
339
+
330
340
  if (open) {
331
341
  setSearch("");
332
342
  setSelectedIndex(0);
@@ -497,6 +507,8 @@ export function CommandMenu({
497
507
  React.isValidElement(child) &&
498
508
  (child.type === CommandGroup || child.type === CommandDocsGroup),
499
509
  );
510
+ const dynamicResults = open ? renderResults?.(search) : null;
511
+ const hasDynamicResults = Boolean(dynamicResults);
500
512
 
501
513
  return (
502
514
  <>
@@ -528,6 +540,7 @@ export function CommandMenu({
528
540
 
529
541
  {/* Command list */}
530
542
  <div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
543
+ {dynamicResults}
531
544
  {hasResults && filteredChildren}
532
545
 
533
546
  {/* What's new — built-in changelog entry */}
@@ -567,7 +580,9 @@ export function CommandMenu({
567
580
  {/* Ask AI — always visible at the bottom */}
568
581
  {showAgentFallback && (
569
582
  <>
570
- {(hasResults || showChangelogRow) && <CommandSeparator />}
583
+ {(hasResults || showChangelogRow || hasDynamicResults) && (
584
+ <CommandSeparator />
585
+ )}
571
586
  <div className="p-1">
572
587
  <div
573
588
  className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none"
@@ -658,16 +673,21 @@ CommandMenu.Separator = CommandSeparator;
658
673
  /**
659
674
  * Hook to handle Cmd+K (or Ctrl+K) to open the command menu
660
675
  */
661
- export function useCommandMenuShortcut(onOpen: () => void) {
676
+ export function useCommandMenuShortcut(
677
+ onOpen: () => void,
678
+ options: { allowContentEditable?: boolean } = {},
679
+ ) {
662
680
  useEffect(() => {
663
681
  const handleKeyDown = (e: KeyboardEvent) => {
664
- if ((e.metaKey || e.ctrlKey) && e.key === "k") {
665
- // Don't trigger if user is typing in an input/textarea
666
- const target = e.target as HTMLElement;
682
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
683
+ // Don't trigger if user is typing in a native form control.
684
+ const target = e.target instanceof HTMLElement ? e.target : null;
685
+ const isContentEditable = target?.isContentEditable;
667
686
  if (
668
- target.tagName === "INPUT" ||
669
- target.tagName === "TEXTAREA" ||
670
- target.isContentEditable
687
+ target?.tagName === "INPUT" ||
688
+ target?.tagName === "TEXTAREA" ||
689
+ target?.tagName === "SELECT" ||
690
+ (!options.allowContentEditable && isContentEditable)
671
691
  ) {
672
692
  return;
673
693
  }
@@ -675,9 +695,11 @@ export function useCommandMenuShortcut(onOpen: () => void) {
675
695
  onOpen();
676
696
  }
677
697
  };
678
- document.addEventListener("keydown", handleKeyDown);
679
- return () => document.removeEventListener("keydown", handleKeyDown);
680
- }, [onOpen]);
698
+ const useCapture = Boolean(options.allowContentEditable);
699
+ document.addEventListener("keydown", handleKeyDown, useCapture);
700
+ return () =>
701
+ document.removeEventListener("keydown", handleKeyDown, useCapture);
702
+ }, [onOpen, options.allowContentEditable]);
681
703
  }
682
704
 
683
705
  export type {
@@ -264,6 +264,12 @@ function hasStructuredFilters(filters: ReturnType<typeof buildFilterSummary>) {
264
264
  function buildGuidance(options: {
265
265
  query: string | undefined;
266
266
  structuredFilters: boolean;
267
+ truncated: boolean;
268
+ hasMore: boolean;
269
+ total: number;
270
+ returned: number;
271
+ offset: number;
272
+ limit: number;
267
273
  }) {
268
274
  const guidance: string[] = [];
269
275
 
@@ -285,6 +291,15 @@ function buildGuidance(options: {
285
291
  );
286
292
  }
287
293
 
294
+ if (options.truncated) {
295
+ const more = options.hasMore
296
+ ? `Fetch the next page with offset ${options.offset + options.returned}.`
297
+ : "This is the last page of the cohort.";
298
+ guidance.push(
299
+ `Returned ${options.returned} of ${options.total} matching deals (limit ${options.limit}, offset ${options.offset}). This is a partial slice — do NOT treat it as the full cohort. Use total for counts/aggregates. ${more} Narrow the filters or, for exhaustive cohort analysis, use provider-api-request with provider = hubspot and stageAs.`,
300
+ );
301
+ }
302
+
288
303
  return guidance.join(" ");
289
304
  }
290
305
 
@@ -293,7 +308,7 @@ export default defineAction({
293
308
  // reusable across continuation retries (no re-fetch on resume).
294
309
  readOnly: true,
295
310
  description:
296
- "Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
311
+ "Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. Both paths are bounded: at most limit deals are returned (default 25, max 100). The structured-filter path returns total as the true matched count and a truncated flag; page with offset (or narrow filters) instead of expecting the whole cohort in one call, since a full enriched cohort can be several MB and overruns extension and context budgets. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
297
312
  schema: z.object({
298
313
  properties: StringListSchema.describe(
299
314
  "Optional comma-separated extra HubSpot deal property names to include.",
@@ -344,7 +359,17 @@ export default defineAction({
344
359
  .min(1)
345
360
  .max(100)
346
361
  .default(25)
347
- .describe("Maximum records to return when query is provided."),
362
+ .describe(
363
+ "Maximum deals to return. Applies to BOTH full-text query results and structured-filter cohorts. The structured-filter path returns at most this many enriched deals (use total for the true matched count and offset to page).",
364
+ ),
365
+ offset: z.coerce
366
+ .number()
367
+ .int()
368
+ .min(0)
369
+ .default(0)
370
+ .describe(
371
+ "Number of structured-filter results to skip before returning limit deals. Use for paging through a large cohort; ignored when query is provided.",
372
+ ),
348
373
  after: z
349
374
  .string()
350
375
  .optional()
@@ -362,6 +387,7 @@ export default defineAction({
362
387
  closedDateTo,
363
388
  query,
364
389
  limit = 25,
390
+ offset = 0,
365
391
  after,
366
392
  }) => {
367
393
  const trimmedQuery = query?.trim();
@@ -414,7 +440,7 @@ export default defineAction({
414
440
  closedDateTo: closedDateTo?.trim(),
415
441
  });
416
442
  const structuredFilters = hasStructuredFilters(filters);
417
- const deals = rawDeals
443
+ const matchedDeals = rawDeals
418
444
  .filter((d) => visibleIds.has(String(d.properties.pipeline)))
419
445
  .map((deal) => enrichDeal(deal, lookups, owners))
420
446
  .filter((deal) => {
@@ -435,18 +461,52 @@ export default defineAction({
435
461
  return matchesDateRange(deal, fromMs, toMs);
436
462
  });
437
463
 
464
+ // The full-text query path is already bounded by HubSpot's own `limit`.
465
+ // EVERY non-query call (with or without structured filters) scans the whole
466
+ // visible deal set, so bound the returned cohort here. This is deliberately
467
+ // NOT gated on `structuredFilters`: the unfiltered `hubspot-deals({})` call
468
+ // is the LARGEST payload of all (every visible deal), so it must be bounded
469
+ // too — a full enriched cohort can be multiple MB, which overruns the
470
+ // extension iframe bridge and the agent context. We
471
+ // keep `total` as the true matched count and signal partial slices via
472
+ // `truncated` so callers never mistake a page for the full cohort.
473
+ // `truncated` means "this response is only part of the cohort" (returned <
474
+ // total) and stays true on the LAST page of a paginated read too; pagination
475
+ // (is there a next page) is reported separately via `hasMore` / `nextOffset`.
476
+ const matchedTotal = matchedDeals.length;
477
+ const deals = trimmedQuery
478
+ ? matchedDeals
479
+ : matchedDeals.slice(offset, offset + limit);
480
+ const truncated = !trimmedQuery && deals.length < matchedTotal;
481
+ const hasMore = !trimmedQuery && offset + deals.length < matchedTotal;
482
+
438
483
  return {
439
484
  deals,
440
485
  stageLabels: lookups.stageLabels,
441
486
  pipelineLabels: lookups.pipelineLabels,
442
- total: deals.length,
487
+ total: matchedTotal,
443
488
  count: deals.length,
444
489
  query: trimmedQuery || null,
445
490
  filters,
446
491
  nextAfter: Array.isArray(dealResult) ? null : dealResult.nextAfter,
492
+ ...(trimmedQuery
493
+ ? {}
494
+ : {
495
+ limit,
496
+ offset,
497
+ truncated,
498
+ hasMore,
499
+ nextOffset: hasMore ? offset + deals.length : null,
500
+ }),
447
501
  guidance: buildGuidance({
448
502
  query: trimmedQuery,
449
503
  structuredFilters,
504
+ truncated,
505
+ hasMore,
506
+ total: matchedTotal,
507
+ returned: deals.length,
508
+ offset,
509
+ limit,
450
510
  }),
451
511
  ...(Array.isArray(dealResult)
452
512
  ? {}
@@ -0,0 +1,6 @@
1
+ # Local database-mode development for Builder/source testing.
2
+ # Copy to .env.local or run `pnpm dev:database` to generate one.
3
+ # These values are local-only. Do not use them for production deploys.
4
+
5
+ AGENT_NATIVE_MODE=database
6
+ BETTER_AUTH_SECRET=<generated-local-development-secret>
@@ -113,10 +113,11 @@ cd templates/content && pnpm action <name> [args]
113
113
  | `attach-content-database-source` | `--databaseId <id>` or `--documentId <id> [--sourceType mock\|builder-cms] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join <json>]` | Attach a source binding; use `items` to add more rows and `details` to match a source onto existing rows |
114
114
  | `change-content-database-source-role` | `--databaseId <id>` or `--documentId <id> --sourceId <id> --relationshipMode items\|details [--join <json>]` | Change an attached source between adding rows and adding matched detail columns without removing the source |
115
115
  | `refresh-content-database-source` | `--databaseId <id>` or `--documentId <id>` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured |
116
+ | `process-builder-body-hydration` | `--sourceId <id> [--documentId <id>] [--limit <n>]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers |
116
117
  | `set-content-database-source-write-mode` | `--databaseId <id>` or `--documentId <id> --liveWritesEnabled true\|false [--allowedWriteModes <json>]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes |
117
118
  | `stage-builder-revision` | `--databaseId <id>` or `--documentId <id>` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder |
118
119
  | `review-content-database-source-change-set` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes |
119
- | `prepare-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate with request semantics/idempotency key; never calls Builder |
120
+ | `prepare-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder |
120
121
  | `validate-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder |
121
122
  | `execute-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` |
122
123
  | `add-database-item` | `--databaseId <id> [--title <text>] [--propertyValues <json>]` | Add a page row to a database, optionally seeding property values |
@@ -208,6 +209,14 @@ Content has two file workflows:
208
209
  Edit reusable symbol content in that emitted source file; do not retarget
209
210
  `entry`, `model`, or `source` in the parent MDX unless a dedicated Builder
210
211
  retargeting workflow is added.
212
+ - **Builder source components:** Builder CMS database body hydration renders
213
+ unsupported provider-native body blocks as `<SourceComponent ... />` markers.
214
+ Treat these as read-only preservation anchors, not editable local blocks.
215
+ Agents may edit surrounding prose, but must not delete, duplicate, move, or
216
+ rewrite `rawRef`, `rawHash`, or marker ids unless a dedicated provider
217
+ conversion workflow exists. Guarded Builder write-back refuses missing,
218
+ tampered, or structurally moved markers so source-native components are not
219
+ lost.
211
220
  - **Picked folders and components:** browser-picked folders can be the
212
221
  source of truth for `.md`/`.mdx` files, but the browser does not expose an
213
222
  absolute path that Vite can compile. Component previews from a picked
@@ -5,6 +5,11 @@ import type {
5
5
  BuilderCmsModelSummary,
6
6
  BuilderCmsModelsResponse,
7
7
  } from "../shared/api.js";
8
+ import {
9
+ builderBlocksHash,
10
+ builderEntryBlocks,
11
+ type BuilderContentEntry,
12
+ } from "../shared/builder-mdx.js";
8
13
  import {
9
14
  normalizeBuilderCmsApiEntry,
10
15
  type BuilderCmsSourceEntry,
@@ -23,6 +28,7 @@ export interface BuilderCmsEntryLiveState {
23
28
  exists: boolean;
24
29
  published: "published" | "draft" | string | null;
25
30
  lastUpdated: number | string | null;
31
+ blocksHash: string | null;
26
32
  id: string | null;
27
33
  }
28
34
 
@@ -42,7 +48,7 @@ const BUILDER_CMS_MAX_READ_LIMIT = 1000;
42
48
  const BUILDER_CMS_PAGE_SIZE = 100;
43
49
  const BUILDER_CMS_READ_RETRIES = 2;
44
50
  const BUILDER_CMS_ENTRY_FIELDS =
45
- "id,name,published,lastUpdated,createdDate,data.title,data.handle,data.url,data.slug,data.date,data.description,data.status,data.author,data.image";
51
+ "id,name,published,lastUpdated,createdDate,data.title,data.handle,data.url,data.slug,data.date,data.description,data.status,data.author,data.image,data.blocks,data.blocksString";
46
52
 
47
53
  function builderContentApiHost() {
48
54
  return (
@@ -76,6 +82,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
76
82
  exists: false,
77
83
  published: null,
78
84
  lastUpdated: null,
85
+ blocksHash: null,
79
86
  id: null,
80
87
  };
81
88
  }
@@ -84,6 +91,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
84
91
  exists: false,
85
92
  published: null,
86
93
  lastUpdated: null,
94
+ blocksHash: null,
87
95
  id: null,
88
96
  };
89
97
  }
@@ -97,6 +105,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
97
105
  exists: false,
98
106
  published: null,
99
107
  lastUpdated: null,
108
+ blocksHash: null,
100
109
  id: null,
101
110
  };
102
111
  }
@@ -116,10 +125,12 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
116
125
  exists: false,
117
126
  published: null,
118
127
  lastUpdated: null,
128
+ blocksHash: null,
119
129
  id: null,
120
130
  };
121
131
  }
122
132
 
133
+ const blocks = builderEntryBlocks(record as BuilderContentEntry);
123
134
  return {
124
135
  exists: true,
125
136
  published:
@@ -129,6 +140,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
129
140
  stringOrNumberFromUnknown(record.updatedDate) ??
130
141
  stringOrNumberFromUnknown(record.updatedAt) ??
131
142
  stringOrNumberFromUnknown(data.updatedAt),
143
+ blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null,
132
144
  id,
133
145
  };
134
146
  }
@@ -618,6 +630,7 @@ export async function readBuilderCmsEntryLiveState(args: {
618
630
  exists: false,
619
631
  published: null,
620
632
  lastUpdated: null,
633
+ blocksHash: null,
621
634
  id: null,
622
635
  };
623
636
  }
@@ -3,6 +3,21 @@ import type {
3
3
  ContentDatabaseSourceFieldMapping,
4
4
  DocumentPropertyValue,
5
5
  } from "../shared/api.js";
6
+ import {
7
+ builderBlocksHash,
8
+ builderEntryBlocks,
9
+ normalizeRemoteUpdatedAt,
10
+ type BuilderContentEntry,
11
+ } from "../shared/builder-mdx.js";
12
+
13
+ export const BUILDER_CMS_BODY_CONTENT_KEY = "__builder.body.content";
14
+ export const BUILDER_CMS_BODY_BLOCKS_HASH_KEY = "__builder.body.blocksHash";
15
+ export const BUILDER_CMS_BODY_SIDECARS_KEY = "__builder.body.sidecarsJson";
16
+ export const BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY =
17
+ "__builder.body.losslessContent";
18
+ export const BUILDER_CMS_BODY_READABLE_MAP_KEY =
19
+ "__builder.body.readableMapJson";
20
+ export const BUILDER_CMS_BODY_BLOCK_COUNT_KEY = "__builder.body.blockCount";
6
21
 
7
22
  export interface BuilderCmsSourceEntry {
8
23
  id: string;
@@ -11,6 +26,7 @@ export interface BuilderCmsSourceEntry {
11
26
  urlPath: string;
12
27
  updatedAt: string;
13
28
  sourceValues: Record<string, DocumentPropertyValue>;
29
+ rawEntry?: BuilderContentEntry;
14
30
  }
15
31
 
16
32
  export interface ExistingBuilderSourceRowIdentity {
@@ -291,6 +307,7 @@ function builderSourceValuesFromRecord(args: {
291
307
  title: string;
292
308
  urlPath: string;
293
309
  updatedAt: string;
310
+ rawEntry: BuilderContentEntry;
294
311
  }) {
295
312
  const values: Record<string, DocumentPropertyValue> = {
296
313
  "data.title": args.title,
@@ -298,6 +315,7 @@ function builderSourceValuesFromRecord(args: {
298
315
  lastUpdated: args.updatedAt,
299
316
  };
300
317
  for (const [key, value] of Object.entries(args.data)) {
318
+ if (key === "blocks" || key === "blocksString") continue;
301
319
  values[`data.${key}`] = sourceValueFromUnknown(value);
302
320
  }
303
321
  for (const key of ["published", "createdDate", "updatedDate", "updatedAt"]) {
@@ -305,6 +323,11 @@ function builderSourceValuesFromRecord(args: {
305
323
  values[key] = sourceValueFromUnknown(args.record[key]);
306
324
  }
307
325
  }
326
+ const blocks = builderEntryBlocks(args.rawEntry);
327
+ if (blocks.length > 0) {
328
+ values[BUILDER_CMS_BODY_BLOCKS_HASH_KEY] = builderBlocksHash(blocks);
329
+ values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] = blocks.length;
330
+ }
308
331
  return values;
309
332
  }
310
333
 
@@ -342,6 +365,24 @@ export function normalizeBuilderCmsApiEntry(
342
365
  ]) ??
343
366
  timestampStringFromRecord(data, ["updatedAt"]) ??
344
367
  new Date().toISOString();
368
+ const rawEntry: BuilderContentEntry = {
369
+ ...record,
370
+ id,
371
+ model,
372
+ name:
373
+ typeof record.name === "string" && record.name.trim()
374
+ ? record.name.trim()
375
+ : title,
376
+ published:
377
+ typeof record.published === "string" ? record.published : undefined,
378
+ lastUpdated: normalizeRemoteUpdatedAt({
379
+ ...record,
380
+ id,
381
+ model,
382
+ data,
383
+ } as BuilderContentEntry),
384
+ data,
385
+ };
345
386
 
346
387
  return {
347
388
  id,
@@ -355,6 +396,8 @@ export function normalizeBuilderCmsApiEntry(
355
396
  title,
356
397
  urlPath,
357
398
  updatedAt,
399
+ rawEntry,
358
400
  }),
401
+ rawEntry,
359
402
  };
360
403
  }
@@ -194,6 +194,70 @@ function nestedBuilderPatch(
194
194
  return body;
195
195
  }
196
196
 
197
+ function mergeBuilderPatch(
198
+ base: Record<string, unknown>,
199
+ patch: Record<string, unknown>,
200
+ ) {
201
+ const merged = { ...base };
202
+ for (const [key, value] of Object.entries(patch)) {
203
+ if (
204
+ key === "data" &&
205
+ value &&
206
+ typeof value === "object" &&
207
+ !Array.isArray(value)
208
+ ) {
209
+ merged.data = {
210
+ ...((merged.data && typeof merged.data === "object"
211
+ ? merged.data
212
+ : {}) as Record<string, unknown>),
213
+ ...(value as Record<string, unknown>),
214
+ };
215
+ continue;
216
+ }
217
+ merged[key] = value;
218
+ }
219
+ return merged;
220
+ }
221
+
222
+ function builderBodyPatch(changeSet: ContentDatabaseSourceChangeSet) {
223
+ const bodyChange = changeSet.bodyChange;
224
+ const checks: string[] = [];
225
+ const blockers: string[] = [];
226
+ if (!bodyChange) {
227
+ return { patch: {}, checks, blockers, hasBodyOperation: false };
228
+ }
229
+ if (!bodyChange.proposedBlocksJson) {
230
+ blockers.push(
231
+ "Builder body diff could not be converted to Builder blocks.",
232
+ );
233
+ return { patch: {}, checks, blockers, hasBodyOperation: false };
234
+ }
235
+ try {
236
+ const blocks = JSON.parse(bodyChange.proposedBlocksJson) as unknown;
237
+ if (!Array.isArray(blocks)) {
238
+ blockers.push("Builder body diff did not produce a blocks array.");
239
+ return { patch: {}, checks, blockers, hasBodyOperation: false };
240
+ }
241
+ checks.push("Includes converted Builder body blocks.");
242
+ for (const warning of bodyChange.warnings ?? []) {
243
+ checks.push(`Body conversion warning: ${warning}`);
244
+ }
245
+ return {
246
+ patch: {
247
+ data: {
248
+ blocks,
249
+ },
250
+ },
251
+ checks,
252
+ blockers,
253
+ hasBodyOperation: true,
254
+ };
255
+ } catch {
256
+ blockers.push("Builder body diff contains invalid converted blocks JSON.");
257
+ return { patch: {}, checks, blockers, hasBodyOperation: false };
258
+ }
259
+ }
260
+
197
261
  function builderRequestForEffect(args: {
198
262
  effect: BuilderCmsWriteEffect;
199
263
  model: string;
@@ -272,18 +336,19 @@ function builderSafetyChecks(args: {
272
336
  entryId: string | null;
273
337
  syntheticFixtureTarget: boolean;
274
338
  operations: BuilderCmsExecutionOperation[];
339
+ hasBodyOperation: boolean;
340
+ bodyChecks: string[];
341
+ bodyBlockers: string[];
275
342
  }) {
276
343
  const checks = [
277
344
  "Requires explicit approval before execution.",
278
345
  "Uses the stored execution idempotency key.",
346
+ ...args.bodyChecks,
279
347
  ];
280
- const blockers: string[] = [];
348
+ const blockers: string[] = [...args.bodyBlockers];
281
349
 
282
- if (args.operations.length === 0) {
283
- blockers.push("No field operations are available for this Builder change.");
284
- }
285
- if (args.changeSet.bodyChange) {
286
- blockers.push("Builder body diffs are not executable in this slice.");
350
+ if (args.operations.length === 0 && !args.hasBodyOperation) {
351
+ blockers.push("No Builder operations are available for this change.");
287
352
  }
288
353
  if (args.effect === "autosave" || args.effect === "update_in_place") {
289
354
  const label = args.effect === "autosave" ? "Autosave" : "Update in place";
@@ -418,7 +483,11 @@ export function buildBuilderCmsExecutionPlan(args: {
418
483
  localFieldKey: field.localFieldKey,
419
484
  value: field.proposedValue,
420
485
  }));
421
- const bodyPatch = nestedBuilderPatch(operations);
486
+ const bodyDiffPatch = builderBodyPatch(args.changeSet);
487
+ const bodyPatch = mergeBuilderPatch(
488
+ nestedBuilderPatch(operations),
489
+ bodyDiffPatch.patch,
490
+ );
422
491
  // State-preserving effects must not include `published` in the body. Builder
423
492
  // PATCH preserves omitted publication state, so only transition/create effects
424
493
  // are allowed to set it.
@@ -441,6 +510,9 @@ export function buildBuilderCmsExecutionPlan(args: {
441
510
  args.source.sourceTable === SAFE_WRITE_MODEL &&
442
511
  target?.isSyntheticFixture === true,
443
512
  operations,
513
+ hasBodyOperation: bodyDiffPatch.hasBodyOperation,
514
+ bodyChecks: bodyDiffPatch.checks,
515
+ bodyBlockers: bodyDiffPatch.blockers,
444
516
  });
445
517
  const state: ContentDatabaseSourceExecutionState =
446
518
  safety.blockers.length > 0