@agent-native/core 0.81.3 → 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 (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/core/src/file-upload/builder.ts +199 -37
  6. package/corpus/core/src/file-upload/index.ts +2 -0
  7. package/corpus/core/src/file-upload/types.ts +38 -0
  8. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  9. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  10. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  11. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  12. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  13. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  14. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  15. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  16. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  17. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  18. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  19. package/corpus/templates/content/.env.local.example +6 -0
  20. package/corpus/templates/content/AGENTS.md +10 -1
  21. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  22. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  23. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  24. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  25. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  26. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  27. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  28. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  29. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  30. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  31. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  32. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  33. package/corpus/templates/content/actions/update-document.ts +93 -0
  34. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  35. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  36. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
  37. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  38. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  39. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  40. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  41. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  42. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  43. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  44. package/corpus/templates/content/app/global.css +10 -3
  45. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  46. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  47. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  48. package/corpus/templates/content/app/i18n-data.ts +212 -0
  49. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  50. package/corpus/templates/content/app/root.tsx +236 -8
  51. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  52. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  53. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  54. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  55. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  56. package/corpus/templates/content/package.json +3 -1
  57. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  58. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  59. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  60. package/corpus/templates/content/server/db/schema.ts +27 -0
  61. package/corpus/templates/content/server/plugins/db.ts +33 -0
  62. package/corpus/templates/content/shared/api.ts +45 -0
  63. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  64. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  65. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  67. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  68. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  69. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  70. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  71. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  72. package/dist/client/CommandMenu.d.ts +6 -2
  73. package/dist/client/CommandMenu.d.ts.map +1 -1
  74. package/dist/client/CommandMenu.js +25 -14
  75. package/dist/client/CommandMenu.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/routes.d.ts +1 -1
  79. package/dist/file-upload/builder.d.ts.map +1 -1
  80. package/dist/file-upload/builder.js +137 -25
  81. package/dist/file-upload/builder.js.map +1 -1
  82. package/dist/file-upload/index.d.ts +1 -1
  83. package/dist/file-upload/index.d.ts.map +1 -1
  84. package/dist/file-upload/index.js.map +1 -1
  85. package/dist/file-upload/types.d.ts +26 -0
  86. package/dist/file-upload/types.d.ts.map +1 -1
  87. package/dist/file-upload/types.js.map +1 -1
  88. package/dist/notifications/routes.d.ts +3 -3
  89. package/dist/progress/routes.d.ts +1 -1
  90. package/dist/resources/handlers.d.ts +3 -3
  91. package/dist/server/transcribe-voice.d.ts +1 -1
  92. package/package.json +1 -1
@@ -17,6 +17,7 @@ import {
17
17
  readAppState,
18
18
  writeAppState,
19
19
  } from "@agent-native/core/application-state";
20
+ import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
20
21
  import { runWithRequestContext } from "@agent-native/core/server";
21
22
  import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
22
23
  import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
@@ -40,12 +41,23 @@ import {
40
41
  getEventOwnerContext,
41
42
  ownerEmailMatches,
42
43
  } from "../../../../lib/recordings.js";
44
+ import {
45
+ getResumableSession,
46
+ setResumableSession,
47
+ type StoredResumableSession,
48
+ } from "../../../../lib/resumable-session.js";
43
49
  import {
44
50
  shouldRejectVideoUploadWithoutStorage,
45
51
  STORAGE_SETUP_REQUIRED_REASON,
46
52
  } from "../../../../lib/video-storage.js";
53
+
47
54
  const RECORDING_TOO_LARGE_REASON = `Recording exceeds the ${Math.round(MAX_RECORDING_UPLOAD_BYTES / (1024 * 1024))} MB size limit. Please record a shorter clip.`;
48
55
 
56
+ // Netlify functions have a 6 MB buffered request cap, but binary requests
57
+ // are base64 encoded by the gateway and effectively cap out around 4.5 MB.
58
+ // Keep our own cap lower so dev/local failures match production.
59
+ const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
60
+
49
61
  const ALLOWED_RECORDING_MIME_TYPES = new Set([
50
62
  "video/webm",
51
63
  "video/mp4",
@@ -115,10 +127,6 @@ export default defineEventHandler(async (event: H3Event) => {
115
127
  throw createError({ statusCode: 400, message: "Invalid chunk index" });
116
128
  }
117
129
 
118
- // Netlify functions have a 6 MB buffered request cap, but binary requests
119
- // are base64 encoded by the gateway and effectively cap out around 4.5 MB.
120
- // Keep our own cap lower so dev/local failures match production.
121
- const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
122
130
  const contentLength = Number(getHeader(event, "content-length") || 0);
123
131
  if (contentLength > MAX_CHUNK_BYTES) {
124
132
  setResponseStatus(event, 413);
@@ -164,6 +172,20 @@ export default defineEventHandler(async (event: H3Event) => {
164
172
  throw createError({ statusCode: 404, message: "Recording not found" });
165
173
  }
166
174
 
175
+ // Resumable streaming path — forward chunks directly to the provider.
176
+ const resumableSession = await getResumableSession(recordingId);
177
+ if (resumableSession) {
178
+ return handleResumableChunk(
179
+ event,
180
+ resumableSession,
181
+ recordingId,
182
+ index,
183
+ isFinal,
184
+ mimeType,
185
+ query,
186
+ );
187
+ }
188
+
167
189
  const failedUploadResponse = (reason: string, bytes?: number) => {
168
190
  setResponseStatus(
169
191
  event,
@@ -183,6 +205,13 @@ export default defineEventHandler(async (event: H3Event) => {
183
205
  );
184
206
  }
185
207
 
208
+ // Already finalized — retried final chunk after session was deleted. Skip
209
+ // buffered path writes so recording-upload-* state stays correct.
210
+ if (existing.status === "ready") {
211
+ return { ok: true, finalized: true };
212
+ }
213
+
214
+ // Store chunks in application_state, assemble on finalize.
186
215
  if (await shouldRejectVideoUploadWithoutStorage()) {
187
216
  const now = new Date().toISOString();
188
217
  await db
@@ -362,10 +391,7 @@ export default defineEventHandler(async (event: H3Event) => {
362
391
 
363
392
  await db
364
393
  .update(schema.recordings)
365
- .set({
366
- uploadProgress: progress,
367
- updatedAt: new Date().toISOString(),
368
- })
394
+ .set({ uploadProgress: progress, updatedAt: new Date().toISOString() })
369
395
  .where(
370
396
  and(
371
397
  eq(schema.recordings.id, recordingId),
@@ -399,21 +425,9 @@ export default defineEventHandler(async (event: H3Event) => {
399
425
  if (failedResponse) return failedResponse;
400
426
  debugLog("[chunk] isFinal — invoking finalize", { recordingId });
401
427
  try {
402
- const result = await finalizeRecording.run({
403
- id: recordingId,
404
- durationMs: normalizeChunkUploadNumber(query.durationMs),
405
- width: normalizeChunkUploadNumber(query.width),
406
- height: normalizeChunkUploadNumber(query.height),
407
- hasAudio:
408
- query.hasAudio === undefined
409
- ? undefined
410
- : query.hasAudio === "1" || query.hasAudio === "true",
411
- hasCamera:
412
- query.hasCamera === undefined
413
- ? undefined
414
- : query.hasCamera === "1" || query.hasCamera === "true",
415
- mimeType,
416
- });
428
+ const result = await finalizeRecording.run(
429
+ buildFinalizeArgs(recordingId, mimeType, query),
430
+ );
417
431
  debugLog("[chunk] finalize ok", {
418
432
  recordingId,
419
433
  videoUrl: (result as any)?.videoUrl,
@@ -510,11 +524,154 @@ export default defineEventHandler(async (event: H3Event) => {
510
524
  }
511
525
  }
512
526
 
513
- return {
514
- ok: true,
515
- finalized: false,
516
- index,
517
- bytes: bytes.byteLength,
518
- };
527
+ return { ok: true, finalized: false, index, bytes: bytes.byteLength };
519
528
  });
520
529
  });
530
+
531
+ function buildFinalizeArgs(
532
+ recordingId: string,
533
+ mimeType: string,
534
+ query: Record<string, unknown>,
535
+ ) {
536
+ return {
537
+ id: recordingId,
538
+ durationMs: normalizeChunkUploadNumber(query.durationMs),
539
+ width: normalizeChunkUploadNumber(query.width),
540
+ height: normalizeChunkUploadNumber(query.height),
541
+ hasAudio:
542
+ query.hasAudio === undefined
543
+ ? undefined
544
+ : query.hasAudio === "1" || query.hasAudio === "true",
545
+ hasCamera:
546
+ query.hasCamera === undefined
547
+ ? undefined
548
+ : query.hasCamera === "1" || query.hasCamera === "true",
549
+ mimeType,
550
+ };
551
+ }
552
+
553
+ // Resumable streaming path: each chunk is forwarded directly to the upload
554
+ // provider. Always returns a response — never falls through to the buffered path.
555
+ async function handleResumableChunk(
556
+ event: H3Event,
557
+ session: StoredResumableSession,
558
+ recordingId: string,
559
+ index: number,
560
+ isFinal: boolean,
561
+ mimeType: string,
562
+ query: Record<string, unknown>,
563
+ ) {
564
+ const uploadProvider = getActiveFileUploadProvider();
565
+ console.log(
566
+ `[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
567
+ );
568
+
569
+ const raw = await readRawBody(event, false);
570
+ const bytes: Uint8Array = raw ?? new Uint8Array(0);
571
+
572
+ if (!isFinal && bytes.byteLength === 0) {
573
+ throw createError({ statusCode: 400, message: "Empty chunk body" });
574
+ }
575
+
576
+ if (isFinal && bytes.byteLength === 0) {
577
+ // 0-byte sentinel from the recorder after stop(). All data chunks have
578
+ // already been PUT to the provider; send Content-Range: bytes */<total>
579
+ // to close the session before handing off to finalize-recording.
580
+ const closeRes = await uploadProvider!.resumable!.relayChunk(
581
+ { sessionId: session.sessionId, meta: session.meta },
582
+ `bytes */${session.bytesUploaded}`,
583
+ new Uint8Array(0),
584
+ );
585
+ if (!closeRes.ok) {
586
+ console.error(
587
+ `[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
588
+ );
589
+ setResponseStatus(event, 502);
590
+ return {
591
+ ok: false,
592
+ error: `Resumable session close failed (${closeRes.status})`,
593
+ };
594
+ }
595
+ } else {
596
+ // Idempotent replay guard: a client retry (after a lost response) can
597
+ // re-send a chunk we already committed. Re-PUTing it at the new offset
598
+ // would corrupt the file — detect the duplicate by index and skip the PUT.
599
+ // Chunks are strictly sequential, so any index <= last committed is a replay.
600
+ // A replayed non-final is acked here; a replayed final falls through to
601
+ // finalize, which is idempotent.
602
+ const isReplay = index <= (session.lastCommittedIndex ?? -1);
603
+ if (isReplay) {
604
+ console.warn(
605
+ `[resumable-chunk-${recordingId}] duplicate chunk ${index}, acking without re-upload`,
606
+ );
607
+ if (!isFinal) {
608
+ return {
609
+ ok: true,
610
+ finalized: false,
611
+ index,
612
+ bytes: bytes.byteLength,
613
+ duplicate: true,
614
+ };
615
+ }
616
+ } else {
617
+ // Forward the data chunk to the provider and advance offsets only after
618
+ // the provider confirms receipt (308 Resume Incomplete for non-final, 2xx for final).
619
+ const start = session.bytesUploaded;
620
+ const end = start + bytes.byteLength - 1;
621
+ const contentRange = isFinal
622
+ ? `bytes ${start}-${end}/${start + bytes.byteLength}`
623
+ : `bytes ${start}-${end}/*`;
624
+
625
+ const putT0 = Date.now();
626
+ const putResult = await uploadProvider!.resumable!.relayChunk(
627
+ { sessionId: session.sessionId, meta: session.meta },
628
+ contentRange,
629
+ bytes,
630
+ { mimeType: mimeType.split(";")[0].trim() },
631
+ );
632
+ console.log(
633
+ `[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
634
+ );
635
+
636
+ const resultOk = isFinal
637
+ ? putResult.ok && putResult.status !== 308
638
+ : putResult.ok;
639
+ if (!resultOk) {
640
+ setResponseStatus(event, 502);
641
+ return {
642
+ ok: false,
643
+ error: `Chunk upload failed (${putResult.status})`,
644
+ };
645
+ }
646
+
647
+ await setResumableSession(recordingId, {
648
+ ...session,
649
+ ...(putResult.updatedMeta
650
+ ? { meta: { ...session.meta, ...putResult.updatedMeta } }
651
+ : {}),
652
+ bytesUploaded: start + bytes.byteLength,
653
+ lastCommittedIndex: index,
654
+ });
655
+
656
+ if (!isFinal) {
657
+ return { ok: true, finalized: false, index, bytes: bytes.byteLength };
658
+ }
659
+ }
660
+ }
661
+
662
+ // isFinal — delegate to finalize-recording, which reads the resumable
663
+ // session and calls provider.resumable.completeSession.
664
+ try {
665
+ const result = await finalizeRecording.run(
666
+ buildFinalizeArgs(recordingId, mimeType, query),
667
+ );
668
+ return { ok: true, finalized: true, ...result };
669
+ } catch (err) {
670
+ console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
671
+ setResponseStatus(event, 500);
672
+ return {
673
+ ok: false,
674
+ error: err instanceof Error ? err.message : "Finalize failed",
675
+ };
676
+ }
677
+ }
@@ -44,6 +44,7 @@ import {
44
44
  getEventOwnerContext,
45
45
  ownerEmailMatches,
46
46
  } from "../../../../lib/recordings.js";
47
+ import { deleteResumableSession } from "../../../../lib/resumable-session.js";
47
48
 
48
49
  interface CompressionMeta {
49
50
  originalBytes?: number;
@@ -112,6 +113,9 @@ export default defineEventHandler(async (event: H3Event) => {
112
113
  const cleared = await deleteAppStateByPrefix(
113
114
  `recording-chunks-${recordingId}-`,
114
115
  );
116
+ // Clear any stale resumable session so a buffered retry does not
117
+ // accidentally route through handleResumableChunk with stale offsets.
118
+ await deleteResumableSession(recordingId).catch(() => {});
115
119
 
116
120
  // Reset the per-recording upload progress so the UI poller sees the
117
121
  // re-upload restart from 0 and doesn't briefly show "100% then
@@ -46,6 +46,11 @@ export function pickMimeType(): string {
46
46
  return "";
47
47
  }
48
48
 
49
+ /** How the client delivers recorded data to the server.
50
+ * - `"streaming"` — chunks are flushed to GCS during recording via a resumable session
51
+ * - `"buffered"` — full blob assembled after stop() and uploaded in slices */
52
+ export type UploadMode = "streaming" | "buffered";
53
+
49
54
  /** Query params understood by the chunk-upload route
50
55
  * (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in
51
56
  * `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */
@@ -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