@agent-native/core 0.101.7 → 0.101.11

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 (219) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +28 -0
  3. package/corpus/core/docs/content/deployment.mdx +9 -7
  4. package/corpus/core/docs/content/locales/ar-SA/deployment.mdx +9 -7
  5. package/corpus/core/docs/content/locales/de-DE/deployment.mdx +9 -7
  6. package/corpus/core/docs/content/locales/es-ES/deployment.mdx +9 -7
  7. package/corpus/core/docs/content/locales/fr-FR/deployment.mdx +9 -7
  8. package/corpus/core/docs/content/locales/hi-IN/deployment.mdx +9 -7
  9. package/corpus/core/docs/content/locales/ja-JP/deployment.mdx +9 -7
  10. package/corpus/core/docs/content/locales/ko-KR/deployment.mdx +9 -7
  11. package/corpus/core/docs/content/locales/pt-BR/deployment.mdx +9 -7
  12. package/corpus/core/docs/content/locales/zh-CN/deployment.mdx +9 -7
  13. package/corpus/core/docs/content/locales/zh-TW/deployment.mdx +9 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/client.ts +23 -1
  16. package/corpus/core/src/a2a/handlers.ts +65 -1
  17. package/corpus/core/src/a2a/server.ts +124 -0
  18. package/corpus/core/src/a2a/task-store.ts +253 -0
  19. package/corpus/core/src/a2a/types.ts +26 -0
  20. package/corpus/core/src/agent/production-agent.ts +5 -2
  21. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  22. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  25. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  26. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  27. package/corpus/core/src/scripts/call-agent.ts +22 -3
  28. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  29. package/corpus/core/src/server/auth.ts +25 -1
  30. package/corpus/core/src/server/builder-browser.ts +353 -1
  31. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  33. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  34. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  35. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  36. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  37. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  38. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  39. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  40. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  41. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  42. package/corpus/templates/content/AGENTS.md +3 -2
  43. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  44. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  45. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  46. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  47. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  48. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  49. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  50. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  51. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  52. package/corpus/templates/content/actions/_database-source-utils.ts +1041 -154
  53. package/corpus/templates/content/actions/_database-utils.ts +89 -12
  54. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  55. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  56. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  57. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  58. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  59. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  60. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  61. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  62. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  63. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  64. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  65. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +27 -15
  66. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  67. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  68. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  69. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2353 -576
  71. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  72. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  73. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  74. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  75. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  76. package/corpus/templates/content/app/global.css +71 -1
  77. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  78. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  79. package/corpus/templates/content/app/i18n/zh-TW.ts +38 -0
  80. package/corpus/templates/content/app/i18n-data.ts +450 -0
  81. package/corpus/templates/content/app/root.tsx +2 -0
  82. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  83. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  84. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  85. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  86. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  87. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  88. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  89. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  90. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  91. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  92. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  93. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  94. package/corpus/templates/content/package.json +1 -0
  95. package/corpus/templates/content/parity/matrix.md +27 -25
  96. package/corpus/templates/content/parity/matrix.ts +50 -2
  97. package/corpus/templates/content/server/db/schema.ts +47 -0
  98. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  99. package/corpus/templates/content/server/plugins/db.ts +53 -0
  100. package/corpus/templates/content/shared/api.ts +70 -1
  101. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  102. package/corpus/templates/content/shared/document-export.ts +296 -25
  103. package/corpus/templates/content/shared/inline-math.ts +128 -0
  104. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  105. package/corpus/templates/content/shared/nfm.ts +20 -17
  106. package/corpus/templates/content/vite.config.ts +0 -1
  107. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  108. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  109. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  110. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  111. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  112. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  113. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  114. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  115. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  116. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  117. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  118. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  119. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  120. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  121. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  122. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  123. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  124. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  125. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  126. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  127. package/corpus/templates/mail/AGENTS.md +21 -19
  128. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  129. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  130. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  131. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  132. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  133. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  134. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  135. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  136. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  137. package/dist/a2a/client.d.ts +5 -1
  138. package/dist/a2a/client.d.ts.map +1 -1
  139. package/dist/a2a/client.js +18 -1
  140. package/dist/a2a/client.js.map +1 -1
  141. package/dist/a2a/handlers.d.ts.map +1 -1
  142. package/dist/a2a/handlers.js +57 -8
  143. package/dist/a2a/handlers.js.map +1 -1
  144. package/dist/a2a/server.d.ts.map +1 -1
  145. package/dist/a2a/server.js +87 -1
  146. package/dist/a2a/server.js.map +1 -1
  147. package/dist/a2a/task-store.d.ts +27 -0
  148. package/dist/a2a/task-store.d.ts.map +1 -1
  149. package/dist/a2a/task-store.js +216 -0
  150. package/dist/a2a/task-store.js.map +1 -1
  151. package/dist/a2a/types.d.ts +24 -0
  152. package/dist/a2a/types.d.ts.map +1 -1
  153. package/dist/a2a/types.js.map +1 -1
  154. package/dist/agent/production-agent.d.ts +1 -0
  155. package/dist/agent/production-agent.d.ts.map +1 -1
  156. package/dist/agent/production-agent.js +5 -2
  157. package/dist/agent/production-agent.js.map +1 -1
  158. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  159. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  160. package/dist/client/review/ReviewCommentComposer.js +3 -3
  161. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  162. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  163. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  164. package/dist/client/review/ReviewThreadPanel.js +6 -4
  165. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  166. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  167. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  168. package/dist/client/settings/SettingsPanel.js +11 -4
  169. package/dist/client/settings/SettingsPanel.js.map +1 -1
  170. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  171. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  172. package/dist/client/settings/useBuilderStatus.js +19 -6
  173. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  174. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  175. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  176. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  177. package/dist/collab/awareness.d.ts +2 -2
  178. package/dist/collab/awareness.d.ts.map +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  181. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  182. package/dist/mcp/builtin-tools.js +22 -3
  183. package/dist/mcp/builtin-tools.js.map +1 -1
  184. package/dist/notifications/routes.d.ts +3 -3
  185. package/dist/resources/handlers.d.ts +2 -2
  186. package/dist/scripts/call-agent.d.ts +1 -1
  187. package/dist/scripts/call-agent.d.ts.map +1 -1
  188. package/dist/scripts/call-agent.js +19 -1
  189. package/dist/scripts/call-agent.js.map +1 -1
  190. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  191. package/dist/server/agent-chat-plugin.js +71 -2
  192. package/dist/server/agent-chat-plugin.js.map +1 -1
  193. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  194. package/dist/server/auth.d.ts +1 -0
  195. package/dist/server/auth.d.ts.map +1 -1
  196. package/dist/server/auth.js +19 -2
  197. package/dist/server/auth.js.map +1 -1
  198. package/dist/server/builder-browser.d.ts +78 -0
  199. package/dist/server/builder-browser.d.ts.map +1 -1
  200. package/dist/server/builder-browser.js +246 -1
  201. package/dist/server/builder-browser.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts +41 -0
  203. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  204. package/dist/server/core-routes-plugin.js +260 -14
  205. package/dist/server/core-routes-plugin.js.map +1 -1
  206. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  207. package/docs/content/deployment.mdx +9 -7
  208. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  209. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  210. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  211. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  212. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  213. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  214. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  215. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  216. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  217. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  218. package/package.json +2 -2
  219. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -49,7 +49,6 @@ import {
49
49
  TooltipTrigger,
50
50
  } from "@agent-native/toolkit/ui/tooltip";
51
51
  import {
52
- BUILDER_CMS_SAFE_WRITE_MODEL,
53
52
  type BuilderCmsModelSummary,
54
53
  type ContentDatabaseItem,
55
54
  type ContentDatabaseResponse,
@@ -57,6 +56,8 @@ import {
57
56
  type ContentDatabaseSourceChangeSet,
58
57
  type ContentDatabaseSourceJoinRequest,
59
58
  type ContentDatabaseSourceReviewPayload,
59
+ type ContentDatabaseSourceWriteMode,
60
+ type ProcessBuilderBodyHydrationResponse,
60
61
  type SourceJoinSuggestion,
61
62
  type ContentDatabasePersonalViewOverrides,
62
63
  type ContentDatabaseView,
@@ -132,6 +133,7 @@ import {
132
133
  import { useQueryClient } from "@tanstack/react-query";
133
134
  import {
134
135
  useEffect,
136
+ useCallback,
135
137
  useMemo,
136
138
  useRef,
137
139
  useState,
@@ -149,6 +151,7 @@ import {
149
151
  useAddContentDatabaseSourceFieldProperty,
150
152
  useAttachContentDatabaseSource,
151
153
  useBuilderCmsModels,
154
+ useCancelPreparedBuilderSourceUpdate,
152
155
  useChangeContentDatabaseSourceRole,
153
156
  useContentDatabase,
154
157
  useContentDatabasePersonalView,
@@ -160,8 +163,10 @@ import {
160
163
  useDuplicateDatabaseItems,
161
164
  useExecuteBuilderSourceExecution,
162
165
  useMoveDatabaseItem,
166
+ useMaterializeBuilderRequiredFields,
163
167
  useNotionDatabaseSources,
164
168
  usePrepareBuilderSourceReview,
169
+ usePreviewBuilderSourceReview,
165
170
  useProcessBuilderBodyHydration,
166
171
  useRefreshContentDatabaseSource,
167
172
  useSetContentDatabaseSourceWriteMode,
@@ -174,9 +179,12 @@ import {
174
179
  useSetDocumentProperty,
175
180
  } from "@/hooks/use-document-properties";
176
181
  import {
182
+ isDocumentUpdateConflict,
177
183
  useDeleteDocument,
178
184
  useDocument,
179
185
  seedDatabaseItemDocumentCaches,
186
+ usePreviewDocumentDraft,
187
+ useUpdatePreviewDocumentDraft,
180
188
  useUpdateDocument,
181
189
  } from "@/hooks/use-documents";
182
190
  import { messagesByLocale } from "@/i18n-data";
@@ -189,6 +197,7 @@ import {
189
197
  isEffectivelyEmptyDocumentContent,
190
198
  previewBodyHydrationIsPending,
191
199
  previewBodyHydrationIsTerminalError,
200
+ previewDraftConflictsWithHydratedBody,
192
201
  shouldIgnorePreviewEmptyNormalization,
193
202
  } from "../body-hydration";
194
203
  import {
@@ -227,7 +236,8 @@ import {
227
236
  import { EmojiPicker } from "../EmojiPicker";
228
237
  import {
229
238
  createPreviewDocumentSaveController,
230
- skippedPreviewDocumentSave,
239
+ deferredPreviewDocumentSave,
240
+ type PreviewDocumentSaveAdapter,
231
241
  } from "../previewDocumentSaveController";
232
242
  import {
233
243
  acquirePreviewDocumentSaveController,
@@ -252,6 +262,26 @@ export interface DatabaseViewProps {
252
262
  const CONTENT_DATABASE_PAGE_SIZE = 100;
253
263
  const CONTENT_DATABASE_MAX_ITEM_LIMIT = 5_000;
254
264
 
265
+ export function databaseSearchExpandedItemLimit(
266
+ searchQuery: string,
267
+ currentLimit: number,
268
+ totalItemCount: number,
269
+ ) {
270
+ if (!searchQuery.trim()) return currentLimit;
271
+ return Math.max(
272
+ currentLimit,
273
+ Math.min(totalItemCount, CONTENT_DATABASE_MAX_ITEM_LIMIT),
274
+ );
275
+ }
276
+
277
+ export function databaseSearchExpansionIsPending(
278
+ searchQuery: string,
279
+ requestedLimit: number,
280
+ responseLimit: number,
281
+ ) {
282
+ return !!searchQuery.trim() && responseLimit < requestedLimit;
283
+ }
284
+
255
285
  export type SortDirection = ContentDatabaseSortDirection;
256
286
  export type DatabaseSort = ContentDatabaseSort;
257
287
  export type FilterOperator = ContentDatabaseFilterOperator;
@@ -291,6 +321,32 @@ export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000;
291
321
  export type PersonalDatabaseViewOverrides =
292
322
  ContentDatabasePersonalViewOverrides;
293
323
 
324
+ type BuilderSourceWriteSettingsInput = {
325
+ sourceId: string;
326
+ writeMode: ContentDatabaseSourceWriteMode;
327
+ allowPublicationTransitions?: boolean;
328
+ };
329
+
330
+ export function previewDraftNeedsConflict(args: {
331
+ returnedDraft: { title: string; content: string };
332
+ pending: { title: string; content: string };
333
+ }) {
334
+ return (
335
+ args.returnedDraft.title !== args.pending.title ||
336
+ args.returnedDraft.content !== args.pending.content
337
+ );
338
+ }
339
+
340
+ export function previewDraftMissingCasRecovery(args: {
341
+ operation: "upsert" | "delete";
342
+ allowCreateRetry: boolean;
343
+ }) {
344
+ if (args.operation === "delete") return "converged" as const;
345
+ return args.allowCreateRetry
346
+ ? ("retry-create" as const)
347
+ : ("failed" as const);
348
+ }
349
+
294
350
  type DatabaseMessageKey = keyof (typeof messagesByLocale)["en-US"]["database"];
295
351
 
296
352
  export function dbText(
@@ -315,6 +371,96 @@ export function dbText(
315
371
  export type CreateDatabaseRowHandler = (
316
372
  title?: string,
317
373
  ) => Promise<ContentDatabaseItem | null>;
374
+
375
+ export function databaseCreatedItemForImmediatePreview(
376
+ response: ContentDatabaseResponse,
377
+ args: {
378
+ databaseId: string;
379
+ parentDocument: Document;
380
+ title: string;
381
+ propertyValues: Record<string, DocumentPropertyValue>;
382
+ now?: string;
383
+ },
384
+ ): ContentDatabaseItem | null {
385
+ const returnedItem = response.items.find(
386
+ (item) => item.id === response.createdItemId,
387
+ );
388
+ if (returnedItem) return returnedItem;
389
+ if (!response.createdItemId || !response.createdDocumentId) return null;
390
+
391
+ const now = args.now ?? new Date().toISOString();
392
+ const position = Math.max(
393
+ 0,
394
+ (response.pagination?.totalItems ?? response.items.length + 1) - 1,
395
+ );
396
+ return {
397
+ id: response.createdItemId,
398
+ databaseId: args.databaseId,
399
+ position,
400
+ properties: response.properties.map((property) => ({
401
+ ...property,
402
+ value: Object.prototype.hasOwnProperty.call(
403
+ args.propertyValues,
404
+ property.definition.id,
405
+ )
406
+ ? args.propertyValues[property.definition.id]
407
+ : null,
408
+ })),
409
+ document: {
410
+ id: response.createdDocumentId,
411
+ parentId: args.parentDocument.id,
412
+ title: args.title.trim(),
413
+ content: "",
414
+ icon: null,
415
+ position,
416
+ isFavorite: false,
417
+ hideFromSearch: args.parentDocument.hideFromSearch,
418
+ visibility: args.parentDocument.visibility,
419
+ accessRole: args.parentDocument.accessRole,
420
+ canEdit: true,
421
+ canManage: args.parentDocument.canManage,
422
+ createdAt: now,
423
+ updatedAt: now,
424
+ },
425
+ };
426
+ }
427
+
428
+ export function databaseBuilderHydrationSourceForItem(
429
+ item: ContentDatabaseItem,
430
+ sources: ContentDatabaseSource[],
431
+ ) {
432
+ const sourceId = item.document.databaseMembership?.sourceId;
433
+ if (!sourceId) return null;
434
+ const itemSource = sources.find((candidate) => candidate.id === sourceId);
435
+ return itemSource?.sourceType === "builder-cms" ? itemSource : null;
436
+ }
437
+
438
+ export function databasePreviewItem(
439
+ items: ContentDatabaseItem[],
440
+ previewDocumentId: string | null,
441
+ createdPreviewItem: ContentDatabaseItem | null,
442
+ ) {
443
+ return (
444
+ items.find((item) => item.document.id === previewDocumentId) ??
445
+ (createdPreviewItem?.document.id === previewDocumentId
446
+ ? createdPreviewItem
447
+ : null)
448
+ );
449
+ }
450
+
451
+ export function databaseCreatedItemNeedsPreview(
452
+ items: ContentDatabaseItem[],
453
+ createdItem: ContentDatabaseItem,
454
+ options: {
455
+ openAfterCreate?: boolean;
456
+ focusInlineTitle?: boolean;
457
+ },
458
+ ) {
459
+ if (options.openAfterCreate !== false) return true;
460
+ if (!options.focusInlineTitle) return false;
461
+ return !items.some((item) => item.id === createdItem.id);
462
+ }
463
+
318
464
  type DatabaseDragPreviewState =
319
465
  | {
320
466
  kind: "view";
@@ -531,6 +677,9 @@ function DatabaseTable({
531
677
  const processBuilderBodies = useProcessBuilderBodyHydration(document.id);
532
678
  const prepareBuilderReview = usePrepareBuilderSourceReview(document.id);
533
679
  const executeBuilderExecution = useExecuteBuilderSourceExecution(document.id);
680
+ const cancelPreparedBuilderUpdate = useCancelPreparedBuilderSourceUpdate(
681
+ document.id,
682
+ );
534
683
  const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id);
535
684
  const setProperty = useSetDocumentProperty(document.id, document.id);
536
685
  const updateDatabaseDocument = useUpdateDocument();
@@ -554,10 +703,19 @@ function DatabaseTable({
554
703
  const personalView = useContentDatabasePersonalView(databaseId);
555
704
  const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId);
556
705
  const source = data?.source ?? null;
557
- const sources = data?.sources ?? (source ? [source] : []);
706
+ const sources = useMemo(
707
+ () => databaseAttachedSources(data?.sources, source),
708
+ [data?.sources, source],
709
+ );
710
+ const builderSources = useMemo(
711
+ () => databaseAttachedBuilderSources(sources, source),
712
+ [sources, source],
713
+ );
558
714
  const [previewDocumentId, setPreviewDocumentId] = useState<string | null>(
559
715
  null,
560
716
  );
717
+ const [createdPreviewItem, setCreatedPreviewItem] =
718
+ useState<ContentDatabaseItem | null>(null);
561
719
  const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
562
720
  const [previewTitleFocusDocumentId, setPreviewTitleFocusDocumentId] =
563
721
  useState<string | null>(null);
@@ -566,6 +724,18 @@ function DatabaseTable({
566
724
  >(null);
567
725
  const [searchOpen, setSearchOpen] = useState(false);
568
726
  const [searchQuery, setSearchQuery] = useState("");
727
+ const searchExpandedItemLimit = databaseSearchExpandedItemLimit(
728
+ searchQuery,
729
+ databaseItemLimit,
730
+ totalItemCount,
731
+ );
732
+ const isSearchExpansionPending = databaseSearchExpansionIsPending(
733
+ searchQuery,
734
+ searchExpandedItemLimit,
735
+ data?.pagination?.limit ?? items.length,
736
+ );
737
+ const isDatabaseViewLoading =
738
+ isDatabaseInitialLoading || isSearchExpansionPending;
569
739
  const [settingsOpen, setSettingsOpen] = useState(false);
570
740
  const [inlineFilterControlsOpen, setInlineFilterControlsOpen] =
571
741
  useState(false);
@@ -580,11 +750,28 @@ function DatabaseTable({
580
750
  null,
581
751
  );
582
752
  const [builderReviewOpen, setBuilderReviewOpen] = useState(false);
753
+
754
+ useEffect(() => {
755
+ if (searchExpandedItemLimit === databaseItemLimit) return;
756
+ setDatabaseItemLimit(searchExpandedItemLimit);
757
+ }, [databaseItemLimit, searchExpandedItemLimit]);
758
+ const [builderReviewSourceId, setBuilderReviewSourceId] = useState<
759
+ string | null
760
+ >(null);
583
761
  const [builderReviewResult, setBuilderReviewResult] =
584
762
  useState<ContentDatabaseSourceReviewPayload | null>(null);
585
763
  const [builderReviewCheckedAt, setBuilderReviewCheckedAt] = useState<
586
764
  string | null
587
765
  >(null);
766
+ const [builderReviewError, setBuilderReviewError] = useState<string | null>(
767
+ null,
768
+ );
769
+ const [
770
+ preparedBuilderReviewConfirmation,
771
+ setPreparedBuilderReviewConfirmation,
772
+ ] = useState<PreparedBuilderReviewConfirmation | null>(null);
773
+ const [builderReviewSelectionRemap, setBuilderReviewSelectionRemap] =
774
+ useState<Record<string, string> | null>(null);
588
775
  const [settingsPanel, setSettingsPanel] =
589
776
  useState<DatabaseSettingsPanel>("main");
590
777
  const [viewConfig, setViewConfig] = useState<ContentDatabaseViewConfig>(
@@ -629,29 +816,22 @@ function DatabaseTable({
629
816
  const personalViewSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
630
817
  null,
631
818
  );
632
- const autoContinueBuilderSourceRef = useRef<string | null>(null);
633
- const builderContinuationWatchdogRef = useRef<{
634
- key: string | null;
635
- refires: number;
636
- }>({ key: null, refires: 0 });
819
+ const autoContinueBuilderSourceRef = useRef<Set<string>>(new Set());
820
+ const builderContinuationWatchdogRef = useRef<Map<string, number>>(new Map());
821
+ const refreshSourceInFlightRef = useRef<string | null>(null);
822
+ const hydrationSourceInFlightRef = useRef<string | null>(null);
823
+ const builderReviewGenerationRef = useRef(0);
824
+ const builderReviewSessionRef = useRef<BuilderReviewSession | null>(null);
637
825
  const [
638
- builderContinuationClientErrorKey,
639
- setBuilderContinuationClientErrorKey,
640
- ] = useState<string | null>(null);
641
- const autoPumpBuilderBodiesRef = useRef<string | null>(null);
642
- const [builderHydrationClientErrorKey, setBuilderHydrationClientErrorKey] =
643
- useState<string | null>(null);
644
- const [builderProgressHighWater, setBuilderProgressHighWater] = useState<{
645
- sourceId: string | null;
646
- fetchedCount: number;
647
- hydratedCount: number;
648
- rowsComplete: boolean;
649
- }>({
650
- sourceId: null,
651
- fetchedCount: 0,
652
- hydratedCount: 0,
653
- rowsComplete: false,
654
- });
826
+ builderContinuationClientErrorKeys,
827
+ setBuilderContinuationClientErrorKeys,
828
+ ] = useState<string[]>([]);
829
+ const autoPumpBuilderBodiesRef = useRef<Set<string>>(new Set());
830
+ const [builderHydrationClientErrorKeys, setBuilderHydrationClientErrorKeys] =
831
+ useState<string[]>([]);
832
+ const [builderProgressHighWater, setBuilderProgressHighWater] = useState<
833
+ Record<string, BuilderSourceProgressHighWater>
834
+ >({});
655
835
  const previewStateRef = useRef<{
656
836
  documentId: string | null;
657
837
  visibleItems: ContentDatabaseItem[];
@@ -708,8 +888,11 @@ function DatabaseTable({
708
888
  activeFilters.length === 0 &&
709
889
  !databaseGroupProperty;
710
890
  const hasResultConstraints = !!searchQuery.trim() || activeFilters.length > 0;
711
- const previewItem =
712
- items.find((item) => item.document.id === previewDocumentId) ?? null;
891
+ const previewItem = databasePreviewItem(
892
+ items,
893
+ previewDocumentId,
894
+ createdPreviewItem,
895
+ );
713
896
  const previousPreviewItem = previewItem
714
897
  ? databaseItemPreviewNeighbor(
715
898
  screenVisibleItems,
@@ -731,18 +914,136 @@ function DatabaseTable({
731
914
  () => databaseSelectedItems(visibleItems, selectedItemIds),
732
915
  [visibleItems, selectedItemIds],
733
916
  );
917
+ const builderReviewSource = databaseBuilderReviewSource(
918
+ sources,
919
+ source,
920
+ builderReviewSourceId,
921
+ );
922
+ const builderReviewPreviewQuery = usePreviewBuilderSourceReview({
923
+ documentId: document.id,
924
+ sourceId: builderReviewSourceId,
925
+ scope: selectedItemIds.length > 0 ? "selected" : "all",
926
+ documentIds: selectedItems.map((item) => item.document.id),
927
+ enabled: builderReviewOpen,
928
+ });
929
+ const completeBuilderReviewPreview =
930
+ builderReviewPreviewQuery.data &&
931
+ builderReviewSource &&
932
+ builderReviewPreviewQuery.data.sourceId === builderReviewSource.id &&
933
+ builderReviewPreviewQuery.data.sourceTable ===
934
+ builderReviewSource.sourceTable
935
+ ? builderReviewPreviewQuery.data
936
+ : null;
734
937
  const builderReviewChangeSets = useMemo(
735
- () => builderReviewableChangeSets(source),
736
- [source],
938
+ () => builderReviewableChangeSets(builderReviewSource),
939
+ [builderReviewSource],
737
940
  );
738
941
  const builderReviewPreview = useMemo(
739
942
  () =>
740
- source?.sourceType === "builder-cms" && builderReviewChangeSets.length > 0
741
- ? buildClientBuilderReviewPayload(source, builderReviewChangeSets)
943
+ builderReviewSource && builderReviewChangeSets.length > 0
944
+ ? buildClientBuilderReviewPayload(
945
+ builderReviewSource,
946
+ builderReviewChangeSets,
947
+ )
742
948
  : null,
743
- [builderReviewChangeSets, source],
949
+ [builderReviewChangeSets, builderReviewSource],
950
+ );
951
+ const activeBuilderReview =
952
+ builderReviewResult ??
953
+ (builderReviewOpen
954
+ ? (completeBuilderReviewPreview?.review ?? null)
955
+ : builderReviewPreview);
956
+ const sourcePendingOperations: DatabaseSourcePendingOperations = {
957
+ attach: attachSource.isPending,
958
+ changeRole: changeSourceRole.isPending,
959
+ refresh:
960
+ refreshSource.isPending || refreshSourceInFlightRef.current !== null,
961
+ hydration:
962
+ processBuilderBodies.isPending ||
963
+ hydrationSourceInFlightRef.current !== null,
964
+ disconnect: disconnectSource.isPending,
965
+ review: prepareBuilderReview.isPending,
966
+ execute: executeBuilderExecution.isPending,
967
+ writeMode: setSourceWriteMode.isPending,
968
+ changeRoleSourceId: pendingMutationSourceId(
969
+ changeSourceRole.isPending,
970
+ changeSourceRole.variables,
971
+ ),
972
+ refreshSourceId:
973
+ refreshSourceInFlightRef.current ??
974
+ pendingMutationSourceId(refreshSource.isPending, refreshSource.variables),
975
+ hydrationSourceId:
976
+ hydrationSourceInFlightRef.current ??
977
+ pendingMutationSourceId(
978
+ processBuilderBodies.isPending,
979
+ processBuilderBodies.variables,
980
+ ),
981
+ disconnectSourceId: pendingMutationSourceId(
982
+ disconnectSource.isPending,
983
+ disconnectSource.variables,
984
+ ),
985
+ reviewSourceId: pendingMutationSourceId(
986
+ prepareBuilderReview.isPending,
987
+ prepareBuilderReview.variables,
988
+ ),
989
+ executeSourceId: pendingMutationSourceId(
990
+ executeBuilderExecution.isPending,
991
+ executeBuilderExecution.variables,
992
+ ),
993
+ writeModeSourceId: pendingMutationSourceId(
994
+ setSourceWriteMode.isPending,
995
+ setSourceWriteMode.variables,
996
+ ),
997
+ };
998
+ const runSourceRefresh = useCallback(
999
+ (sourceId: string, onError?: () => void) => {
1000
+ if (!acquireDatabaseSourceOperation(refreshSourceInFlightRef, sourceId)) {
1001
+ return false;
1002
+ }
1003
+ refreshSource.mutate(
1004
+ { documentId: document.id, sourceId },
1005
+ {
1006
+ onError,
1007
+ onSettled: () => {
1008
+ releaseDatabaseSourceOperation(refreshSourceInFlightRef, sourceId);
1009
+ },
1010
+ },
1011
+ );
1012
+ return true;
1013
+ },
1014
+ [document.id, refreshSource.mutate],
1015
+ );
1016
+ const runBuilderHydration = useCallback(
1017
+ (
1018
+ sourceId: string,
1019
+ options: {
1020
+ onSuccess?: (result: ProcessBuilderBodyHydrationResponse) => void;
1021
+ onError?: () => void;
1022
+ } = {},
1023
+ request: { documentId?: string; limit?: number } = {},
1024
+ ) => {
1025
+ if (
1026
+ !acquireDatabaseSourceOperation(hydrationSourceInFlightRef, sourceId)
1027
+ ) {
1028
+ return false;
1029
+ }
1030
+ processBuilderBodies.mutate(
1031
+ { sourceId, ...request },
1032
+ {
1033
+ onSuccess: options.onSuccess,
1034
+ onError: options.onError,
1035
+ onSettled: () => {
1036
+ releaseDatabaseSourceOperation(
1037
+ hydrationSourceInFlightRef,
1038
+ sourceId,
1039
+ );
1040
+ },
1041
+ },
1042
+ );
1043
+ return true;
1044
+ },
1045
+ [processBuilderBodies.mutate],
744
1046
  );
745
- const activeBuilderReview = builderReviewResult ?? builderReviewPreview;
746
1047
 
747
1048
  useEffect(() => {
748
1049
  previewStateRef.current = {
@@ -758,191 +1059,125 @@ function DatabaseTable({
758
1059
  }, [visibleItems]);
759
1060
 
760
1061
  useEffect(() => {
761
- if (!source || source.sourceType !== "builder-cms") {
762
- autoPumpBuilderBodiesRef.current = null;
763
- setBuilderHydrationClientErrorKey(null);
764
- setBuilderProgressHighWater({
765
- sourceId: null,
766
- fetchedCount: 0,
767
- hydratedCount: 0,
768
- rowsComplete: false,
769
- });
770
- return;
771
- }
772
- const rawFetchedCount =
773
- typeof source.metadata.lastReadFetchedEntryCount === "number"
774
- ? source.metadata.lastReadFetchedEntryCount
775
- : typeof source.metadata.lastReadEntryCount === "number"
776
- ? source.metadata.lastReadEntryCount
777
- : 0;
778
- const fetchedCount =
779
- source.metadata.lastReadHasMore === false
780
- ? Math.max(rawFetchedCount, source.bodyHydration?.total ?? 0)
781
- : rawFetchedCount;
782
- const hydratedCount = source.bodyHydration?.hydrated ?? 0;
783
- const rowsComplete = source.metadata.lastReadHasMore === false;
784
- setBuilderProgressHighWater((current) => {
785
- if (current.sourceId !== source.id) {
786
- return {
787
- sourceId: source.id,
788
- fetchedCount,
789
- hydratedCount,
790
- rowsComplete,
791
- };
792
- }
793
- const next = {
794
- sourceId: source.id,
795
- fetchedCount: Math.max(current.fetchedCount, fetchedCount),
796
- hydratedCount: Math.max(current.hydratedCount, hydratedCount),
797
- rowsComplete: current.rowsComplete || rowsComplete,
798
- };
799
- return next.fetchedCount === current.fetchedCount &&
800
- next.hydratedCount === current.hydratedCount &&
801
- next.rowsComplete === current.rowsComplete
802
- ? current
803
- : next;
804
- });
805
- }, [source]);
1062
+ setBuilderProgressHighWater((current) =>
1063
+ databaseBuilderProgressHighWater(builderSources, current),
1064
+ );
1065
+ }, [builderSources]);
806
1066
 
807
1067
  useEffect(() => {
1068
+ if (!isActive || !canEdit || refreshSource.isPending) return;
1069
+ const candidate = databaseNextBuilderContinuationSource(builderSources, {
1070
+ attemptedKeys: autoContinueBuilderSourceRef.current,
1071
+ errorKeys: new Set(builderContinuationClientErrorKeys),
1072
+ });
1073
+ if (!candidate) return;
1074
+ const continuationKey = builderSourceContinuationKey(candidate);
1075
+ if (!continuationKey) return;
1076
+ databaseRecordBuilderContinuationAttempt(
1077
+ autoContinueBuilderSourceRef.current,
1078
+ continuationKey,
1079
+ );
808
1080
  if (
809
- !isActive ||
810
- !canEdit ||
811
- !source ||
812
- source.sourceType !== "builder-cms" ||
813
- sourceAddsDetails(source) ||
814
- refreshSource.isPending
815
- ) {
816
- return;
817
- }
818
- const sourceStatus = builderSourceRowFetchStatus(source);
819
- if (
820
- sourceStatus !== "fetching" ||
821
- source.metadata.lastReadHasMore !== true
1081
+ !runSourceRefresh(candidate.id, () =>
1082
+ setBuilderContinuationClientErrorKeys((current) =>
1083
+ addUniqueKey(current, continuationKey),
1084
+ ),
1085
+ )
822
1086
  ) {
823
- return;
1087
+ autoContinueBuilderSourceRef.current.delete(continuationKey);
824
1088
  }
825
- const continuationKey = builderSourceContinuationKey(source);
826
- if (!continuationKey) return;
827
- if (builderContinuationClientErrorKey === continuationKey) return;
828
- if (autoContinueBuilderSourceRef.current === continuationKey) return;
829
- autoContinueBuilderSourceRef.current = continuationKey;
830
- refreshSource.mutate(
831
- {
832
- documentId: document.id,
833
- sourceId: source.id,
834
- },
835
- {
836
- onError: () => setBuilderContinuationClientErrorKey(continuationKey),
837
- },
838
- );
839
1089
  }, [
840
- builderContinuationClientErrorKey,
1090
+ builderContinuationClientErrorKeys,
1091
+ builderSources,
841
1092
  canEdit,
842
- document.id,
843
1093
  isActive,
844
- refreshSource.mutate,
845
1094
  refreshSource.isPending,
846
- source,
1095
+ runSourceRefresh,
847
1096
  ]);
848
1097
 
849
1098
  useEffect(() => {
1099
+ if (!isActive || !canEdit || processBuilderBodies.isPending) return;
1100
+ const candidate = databaseNextBuilderHydrationSource(builderSources, {
1101
+ attemptedKeys: autoPumpBuilderBodiesRef.current,
1102
+ errorKeys: new Set(builderHydrationClientErrorKeys),
1103
+ });
1104
+ if (!candidate) return;
1105
+ const hydrationKey = builderBodyHydrationPumpKey(candidate);
1106
+ if (!hydrationKey) return;
1107
+ autoPumpBuilderBodiesRef.current.add(hydrationKey);
850
1108
  if (
851
- !isActive ||
852
- !canEdit ||
853
- !source ||
854
- !shouldPumpBuilderBodyHydration(
855
- source,
856
- processBuilderBodies.isPending,
857
- builderHydrationClientErrorKey,
858
- )
859
- ) {
860
- return;
861
- }
862
- const hydrationKey = builderBodyHydrationPumpKey(source);
863
- if (!hydrationKey || autoPumpBuilderBodiesRef.current === hydrationKey) {
864
- return;
865
- }
866
- autoPumpBuilderBodiesRef.current = hydrationKey;
867
- processBuilderBodies.mutate(
868
- { sourceId: source.id },
869
- {
1109
+ !runBuilderHydration(candidate.id, {
870
1110
  onSuccess: (result) => {
871
1111
  if (!builderBodyHydrationMutationMadeProgress(result)) {
872
- setBuilderHydrationClientErrorKey(hydrationKey);
1112
+ setBuilderHydrationClientErrorKeys((current) =>
1113
+ addUniqueKey(current, hydrationKey),
1114
+ );
873
1115
  }
874
1116
  },
875
- onError: () => setBuilderHydrationClientErrorKey(hydrationKey),
876
- },
877
- );
1117
+ onError: () =>
1118
+ setBuilderHydrationClientErrorKeys((current) =>
1119
+ addUniqueKey(current, hydrationKey),
1120
+ ),
1121
+ })
1122
+ ) {
1123
+ autoPumpBuilderBodiesRef.current.delete(hydrationKey);
1124
+ }
878
1125
  }, [
879
- builderHydrationClientErrorKey,
1126
+ builderHydrationClientErrorKeys,
1127
+ builderSources,
880
1128
  canEdit,
881
1129
  isActive,
882
1130
  processBuilderBodies.isPending,
883
- processBuilderBodies.mutate,
884
- source,
1131
+ runBuilderHydration,
885
1132
  ]);
886
1133
 
887
1134
  useEffect(() => {
888
- const continuationKey =
889
- source?.sourceType === "builder-cms"
890
- ? builderSourceContinuationKey(source)
891
- : null;
892
- if (
893
- !isActive ||
894
- !canEdit ||
895
- !source ||
896
- source.sourceType !== "builder-cms" ||
897
- sourceAddsDetails(source) ||
898
- refreshSource.isPending ||
899
- builderSourceRowFetchStatus(source) !== "fetching" ||
900
- source.metadata.lastReadHasMore !== true ||
901
- !continuationKey ||
902
- builderContinuationClientErrorKey === continuationKey
903
- ) {
904
- return;
905
- }
906
- if (builderContinuationWatchdogRef.current.key !== continuationKey) {
907
- builderContinuationWatchdogRef.current = {
908
- key: continuationKey,
909
- refires: 0,
910
- };
911
- }
912
- const refireDelay = builderSourceContinuationWatchdogDelay(
913
- builderContinuationWatchdogRef.current.refires,
1135
+ if (!isActive || !canEdit || refreshSource.isPending) return;
1136
+ const candidate = databaseNextBuilderContinuationWatchdogSource(
1137
+ builderSources,
1138
+ {
1139
+ attemptedKeys: autoContinueBuilderSourceRef.current,
1140
+ errorKeys: new Set(builderContinuationClientErrorKeys),
1141
+ refiresByKey: builderContinuationWatchdogRef.current,
1142
+ },
914
1143
  );
1144
+ if (!candidate) return;
1145
+ const continuationKey = builderSourceContinuationKey(candidate);
1146
+ if (!continuationKey) return;
1147
+ const refires =
1148
+ builderContinuationWatchdogRef.current.get(continuationKey) ?? 0;
915
1149
  const timer = window.setTimeout(() => {
916
- const watchdog = builderContinuationWatchdogRef.current;
917
- if (watchdog.key !== continuationKey) return;
918
1150
  if (
919
- builderSourceContinuationWatchdogDecision(watchdog.refires) !== "refire"
920
- )
1151
+ builderSourceContinuationWatchdogDecision(refires) !== "refire" ||
1152
+ refreshSource.isPending
1153
+ ) {
921
1154
  return;
922
- builderContinuationWatchdogRef.current = {
923
- key: continuationKey,
924
- refires: watchdog.refires + 1,
925
- };
926
- autoContinueBuilderSourceRef.current = null;
927
- refreshSource.mutate(
928
- {
929
- documentId: document.id,
930
- sourceId: source.id,
931
- },
932
- {
933
- onError: () => setBuilderContinuationClientErrorKey(continuationKey),
934
- },
935
- );
936
- }, refireDelay);
1155
+ }
1156
+ if (
1157
+ runSourceRefresh(candidate.id, () =>
1158
+ setBuilderContinuationClientErrorKeys((current) =>
1159
+ addUniqueKey(current, continuationKey),
1160
+ ),
1161
+ )
1162
+ ) {
1163
+ builderContinuationWatchdogRef.current.set(
1164
+ continuationKey,
1165
+ refires + 1,
1166
+ );
1167
+ databaseRecordBuilderContinuationAttempt(
1168
+ autoContinueBuilderSourceRef.current,
1169
+ continuationKey,
1170
+ );
1171
+ }
1172
+ }, builderSourceContinuationWatchdogDelay(refires));
937
1173
  return () => window.clearTimeout(timer);
938
1174
  }, [
939
- builderContinuationClientErrorKey,
1175
+ builderContinuationClientErrorKeys,
1176
+ builderSources,
940
1177
  canEdit,
941
- document.id,
942
1178
  isActive,
943
- refreshSource.mutate,
944
1179
  refreshSource.isPending,
945
- source,
1180
+ runSourceRefresh,
946
1181
  ]);
947
1182
 
948
1183
  useEffect(() => {
@@ -1047,19 +1282,20 @@ function DatabaseTable({
1047
1282
  }
1048
1283
 
1049
1284
  function prioritizeBuilderBodyHydrationForItem(item: ContentDatabaseItem) {
1050
- const sourceId = item.document.databaseMembership?.sourceId ?? source?.id;
1051
- if (!sourceId) return;
1052
- const builderSource =
1053
- sources.find((candidate) => candidate.id === sourceId) ?? source;
1054
- if (builderSource?.sourceType !== "builder-cms") return;
1285
+ const builderSource = databaseBuilderHydrationSourceForItem(item, sources);
1286
+ if (!builderSource) return;
1287
+ const sourceId = builderSource.id;
1055
1288
  const hydration =
1056
1289
  item.bodyHydration ?? item.document.databaseMembership?.bodyHydration;
1057
1290
  if (hydration && !databaseItemBodyHydrationIsPending(item)) return;
1058
- processBuilderBodies.mutate({
1291
+ runBuilderHydration(
1059
1292
  sourceId,
1060
- documentId: item.document.id,
1061
- limit: 1,
1062
- });
1293
+ {},
1294
+ {
1295
+ documentId: item.document.id,
1296
+ limit: 1,
1297
+ },
1298
+ );
1063
1299
  }
1064
1300
 
1065
1301
  async function createRow(
@@ -1090,14 +1326,21 @@ function DatabaseTable({
1090
1326
  });
1091
1327
  return null;
1092
1328
  }
1093
- const createdItem = response.items.find(
1094
- (item) => item.id === response.createdItemId,
1095
- );
1096
- if (createdItem && options.openAfterCreate !== false) {
1329
+ const createdItem = databaseCreatedItemForImmediatePreview(response, {
1330
+ databaseId,
1331
+ parentDocument: document,
1332
+ title,
1333
+ propertyValues,
1334
+ });
1335
+ const needsPreview =
1336
+ !!createdItem &&
1337
+ databaseCreatedItemNeedsPreview(items, createdItem, options);
1338
+ if (createdItem && needsPreview) {
1339
+ setCreatedPreviewItem(createdItem);
1097
1340
  setPreviewDocumentId(createdItem.document.id);
1098
1341
  setPreviewTitleFocusDocumentId(createdItem.document.id);
1099
1342
  }
1100
- if (createdItem && options.focusInlineTitle) {
1343
+ if (createdItem && options.focusInlineTitle && !needsPreview) {
1101
1344
  setInlineTitleFocusDocumentId(createdItem.document.id);
1102
1345
  }
1103
1346
  return createdItem ?? null;
@@ -1372,73 +1615,376 @@ function DatabaseTable({
1372
1615
  updateActiveView((view) => ({ ...view, hideEmptyGroups }));
1373
1616
  }
1374
1617
 
1375
- async function handleBuilderReviewPush(
1376
- transitions: BuilderReviewPublicationTransitions = {},
1377
- ) {
1618
+ function openBuilderReview(sourceId: string) {
1619
+ if (
1620
+ prepareBuilderReview.isPending ||
1621
+ executeBuilderExecution.isPending ||
1622
+ cancelPreparedBuilderUpdate.isPending
1623
+ ) {
1624
+ return;
1625
+ }
1626
+ const session = {
1627
+ sourceId,
1628
+ generation: builderReviewGenerationRef.current + 1,
1629
+ };
1630
+ builderReviewGenerationRef.current = session.generation;
1631
+ builderReviewSessionRef.current = session;
1632
+ setBuilderReviewSourceId(sourceId);
1378
1633
  setBuilderReviewResult(null);
1379
1634
  setBuilderReviewCheckedAt(null);
1380
- const scopedChangeSetIds =
1381
- activeBuilderReview && activeBuilderReview.rows.length > 0
1382
- ? activeBuilderReview.rows.map((row) => row.changeSetId)
1383
- : undefined;
1384
- try {
1385
- const prepared = await prepareBuilderReview.mutateAsync({
1386
- documentId: document.id,
1387
- sourceId: source?.id,
1388
- changeSetIds: scopedChangeSetIds,
1389
- pushModeConfirmation: "autosave",
1635
+ setBuilderReviewError(null);
1636
+ setPreparedBuilderReviewConfirmation(null);
1637
+ setBuilderReviewSelectionRemap(null);
1638
+ setBuilderReviewOpen(true);
1639
+ }
1640
+
1641
+ function closeBuilderReview(force = false) {
1642
+ if (
1643
+ !force &&
1644
+ (prepareBuilderReview.isPending ||
1645
+ executeBuilderExecution.isPending ||
1646
+ cancelPreparedBuilderUpdate.isPending)
1647
+ ) {
1648
+ return;
1649
+ }
1650
+ builderReviewGenerationRef.current += 1;
1651
+ builderReviewSessionRef.current = null;
1652
+ setBuilderReviewOpen(false);
1653
+ setBuilderReviewSourceId(null);
1654
+ setBuilderReviewResult(null);
1655
+ setBuilderReviewCheckedAt(null);
1656
+ setBuilderReviewError(null);
1657
+ setPreparedBuilderReviewConfirmation(null);
1658
+ setBuilderReviewSelectionRemap(null);
1659
+ }
1660
+
1661
+ async function handleBuilderReviewPush(selection: {
1662
+ changeSetIds: string[];
1663
+ transitions: BuilderReviewPublicationTransitions;
1664
+ }) {
1665
+ const session = builderReviewSessionRef.current;
1666
+ const reviewSource = builderReviewSource;
1667
+ if (
1668
+ !session ||
1669
+ !reviewSource ||
1670
+ session.sourceId !== reviewSource.id ||
1671
+ !databaseBuilderReviewSessionIsCurrent(
1672
+ builderReviewSessionRef.current,
1673
+ session,
1674
+ )
1675
+ ) {
1676
+ return;
1677
+ }
1678
+ if (
1679
+ !activeBuilderReview ||
1680
+ !databaseBuilderReviewSelectedChangeSetIds(
1681
+ activeBuilderReview,
1682
+ reviewSource,
1683
+ selection.changeSetIds,
1684
+ !builderReviewResult
1685
+ ? completeBuilderReviewPreview?.changeSetIds
1686
+ : undefined,
1687
+ )
1688
+ ) {
1689
+ toast.error(dbText("builderUpdateFailed"), {
1690
+ description: dbText("needsAFreshReview"),
1390
1691
  });
1391
- let nextReview = prepared.review;
1692
+ return;
1693
+ }
1694
+ const selectedChangeSetIds = databaseBuilderReviewSelectedChangeSetIds(
1695
+ activeBuilderReview,
1696
+ reviewSource,
1697
+ selection.changeSetIds,
1698
+ !builderReviewResult
1699
+ ? completeBuilderReviewPreview?.changeSetIds
1700
+ : undefined,
1701
+ )!;
1702
+ const selectedChangeSetIdSet = new Set(selectedChangeSetIds);
1703
+ const selectionFingerprint = builderReviewSelectionFingerprint({
1704
+ changeSetIds: selectedChangeSetIds,
1705
+ transitions: selection.transitions,
1706
+ });
1707
+ const shouldExecute = preparedBuilderReviewMatches(
1708
+ preparedBuilderReviewConfirmation,
1709
+ session,
1710
+ selectionFingerprint,
1711
+ );
1392
1712
 
1393
- if (
1394
- nextReview.liveWritesEnabled &&
1395
- nextReview.result.status === "validated"
1396
- ) {
1397
- const executableRows = builderReviewExecutableRows(nextReview);
1398
- let executedResponse: ContentDatabaseResponse | null = null;
1399
- for (const row of executableRows) {
1400
- if (!row.execution?.idempotencyKey) continue;
1401
- const transition = transitions[row.changeSetId];
1402
- executedResponse = await executeBuilderExecution.mutateAsync({
1403
- documentId: document.id,
1404
- changeSetId: row.changeSetId,
1405
- idempotencyKey: row.execution.idempotencyKey,
1406
- pushModeConfirmation: nextReview.pushMode,
1407
- publicationTransition: transition?.publicationTransition,
1408
- confirmUnpublish: transition?.confirmUnpublish,
1409
- });
1713
+ if (!shouldExecute) {
1714
+ setBuilderReviewResult(null);
1715
+ setBuilderReviewCheckedAt(null);
1716
+ setBuilderReviewError(null);
1717
+ setPreparedBuilderReviewConfirmation(null);
1718
+ try {
1719
+ const prepared = await prepareBuilderReview.mutateAsync({
1720
+ documentId: document.id,
1721
+ sourceId: session.sourceId,
1722
+ changeSetIds: selectedChangeSetIds,
1723
+ pushModeConfirmation:
1724
+ activeBuilderReview.pushMode === "none"
1725
+ ? undefined
1726
+ : activeBuilderReview.pushMode,
1727
+ transitions: selection.transitions,
1728
+ });
1729
+ if (
1730
+ !databaseBuilderReviewSessionIsCurrent(
1731
+ builderReviewSessionRef.current,
1732
+ session,
1733
+ )
1734
+ ) {
1735
+ return;
1410
1736
  }
1411
- const executedSource = executedResponse?.source ?? null;
1412
- if (executedSource) {
1413
- const reviewedIds = new Set(
1414
- nextReview.rows.map((row) => row.changeSetId),
1415
- );
1416
- const reviewedChangeSets = executedSource.changeSets.filter(
1417
- (changeSet) => reviewedIds.has(changeSet.id),
1737
+ const preparedSelection = databaseBuilderPreparedReviewSelection(
1738
+ prepared.review,
1739
+ reviewSource,
1740
+ selectedChangeSetIds,
1741
+ prepared.preparedChangeSetMappings,
1742
+ );
1743
+ if (!preparedSelection) {
1744
+ throw new Error(
1745
+ "The prepared Builder review returned changes from another source.",
1418
1746
  );
1419
- if (reviewedChangeSets.length > 0) {
1420
- nextReview = buildClientBuilderReviewPayload(
1421
- executedSource,
1422
- reviewedChangeSets,
1423
- );
1424
- }
1425
1747
  }
1426
- }
1427
1748
 
1428
- setBuilderReviewResult(nextReview);
1429
- setBuilderReviewCheckedAt(new Date().toISOString());
1430
- toast.success(
1431
- nextReview.result.status === "succeeded"
1432
- ? "Builder update pushed"
1433
- : "Builder update checked",
1434
- {
1435
- description: nextReview.result.message,
1749
+ const preparedTransitions = Object.fromEntries(
1750
+ Object.entries(selection.transitions).map(
1751
+ ([changeSetId, transition]) => [
1752
+ preparedSelection.changeSetIdMap[changeSetId] ?? changeSetId,
1753
+ transition,
1754
+ ],
1755
+ ),
1756
+ );
1757
+ const preparedSelectionFingerprint = builderReviewSelectionFingerprint({
1758
+ changeSetIds: preparedSelection.preparedChangeSetIds,
1759
+ transitions: preparedTransitions,
1760
+ });
1761
+ setBuilderReviewSelectionRemap(preparedSelection.changeSetIdMap);
1762
+ setBuilderReviewResult(prepared.review);
1763
+ const executableRows = builderReviewExecutableRows(
1764
+ prepared.review,
1765
+ new Set(preparedSelection.preparedChangeSetIds),
1766
+ );
1767
+ if (
1768
+ prepared.review.liveWritesEnabled &&
1769
+ executableRows.length ===
1770
+ preparedSelection.preparedChangeSetIds.length
1771
+ ) {
1772
+ setPreparedBuilderReviewConfirmation({
1773
+ ...session,
1774
+ selectionFingerprint: preparedSelectionFingerprint,
1775
+ });
1776
+ toast.info(dbText("ready"), {
1777
+ description: dbText("reviewBuilderUpdate"),
1778
+ });
1779
+ return;
1780
+ }
1781
+
1782
+ setBuilderReviewCheckedAt(new Date().toISOString());
1783
+ toast.success(dbText("checkedStatus"), {
1784
+ description: prepared.review.result.message,
1785
+ });
1786
+ } catch (error) {
1787
+ if (
1788
+ !databaseBuilderReviewSessionIsCurrent(
1789
+ builderReviewSessionRef.current,
1790
+ session,
1791
+ )
1792
+ ) {
1793
+ return;
1794
+ }
1795
+ const message =
1796
+ error instanceof Error ? error.message : dbText("tryAgain");
1797
+ setBuilderReviewError(message);
1798
+ toast.error(dbText("builderUpdateFailed"), {
1799
+ description: message,
1800
+ });
1801
+ }
1802
+ return;
1803
+ }
1804
+
1805
+ const preparedReview = builderReviewResult;
1806
+ if (
1807
+ !preparedReview ||
1808
+ !databaseBuilderReviewExactSelectionIsValid(
1809
+ preparedReview,
1810
+ selectedChangeSetIds,
1811
+ )
1812
+ ) {
1813
+ setPreparedBuilderReviewConfirmation(null);
1814
+ setBuilderReviewError(dbText("needsAFreshReview"));
1815
+ return;
1816
+ }
1817
+
1818
+ setBuilderReviewCheckedAt(null);
1819
+ setBuilderReviewError(null);
1820
+ let preparedReviewForError = activeBuilderReview;
1821
+ try {
1822
+ let nextReview = preparedReview;
1823
+ preparedReviewForError = nextReview;
1824
+
1825
+ const executableRows = builderReviewExecutableRows(
1826
+ nextReview,
1827
+ selectedChangeSetIdSet,
1828
+ );
1829
+ let executedResponse: ContentDatabaseResponse | null = null;
1830
+ for (const row of executableRows) {
1831
+ if (!row.execution?.idempotencyKey) continue;
1832
+ if (
1833
+ !databaseBuilderReviewSessionIsCurrent(
1834
+ builderReviewSessionRef.current,
1835
+ session,
1836
+ )
1837
+ ) {
1838
+ return;
1839
+ }
1840
+ const transition = selection.transitions[row.changeSetId];
1841
+ executedResponse = await executeBuilderExecution.mutateAsync({
1842
+ documentId: document.id,
1843
+ sourceId: session.sourceId,
1844
+ changeSetId: row.changeSetId,
1845
+ idempotencyKey: row.execution.idempotencyKey,
1846
+ pushModeConfirmation: nextReview.pushMode,
1847
+ publicationTransition: transition?.publicationTransition,
1848
+ confirmUnpublish: transition?.confirmUnpublish,
1849
+ });
1850
+ if (
1851
+ !databaseBuilderReviewSessionIsCurrent(
1852
+ builderReviewSessionRef.current,
1853
+ session,
1854
+ )
1855
+ ) {
1856
+ return;
1857
+ }
1858
+ }
1859
+ const executedSource = executedResponse
1860
+ ? databaseAttachedBuilderSource(
1861
+ databaseAttachedSources(
1862
+ executedResponse.sources,
1863
+ executedResponse.source,
1864
+ ),
1865
+ executedResponse.source,
1866
+ { sourceId: session.sourceId },
1867
+ )
1868
+ : null;
1869
+ if (executedSource) {
1870
+ const reviewedIds = new Set(
1871
+ nextReview.rows.map((row) => row.changeSetId),
1872
+ );
1873
+ const reviewedChangeSets = executedSource.changeSets.filter(
1874
+ (changeSet) => reviewedIds.has(changeSet.id),
1875
+ );
1876
+ if (reviewedChangeSets.length > 0) {
1877
+ nextReview = buildClientBuilderReviewPayload(
1878
+ executedSource,
1879
+ reviewedChangeSets,
1880
+ );
1881
+ }
1882
+ }
1883
+
1884
+ if (
1885
+ !databaseBuilderReviewSessionIsCurrent(
1886
+ builderReviewSessionRef.current,
1887
+ session,
1888
+ )
1889
+ ) {
1890
+ return;
1891
+ }
1892
+ setBuilderReviewResult(nextReview);
1893
+ setBuilderReviewCheckedAt(new Date().toISOString());
1894
+ setBuilderReviewError(null);
1895
+ setPreparedBuilderReviewConfirmation(null);
1896
+ if (nextReview.result.status === "running") {
1897
+ toast.info(dbText("working"), {
1898
+ description: dbText("builderPushAlreadyRunning"),
1899
+ });
1900
+ return;
1901
+ }
1902
+ toast.success(
1903
+ nextReview.result.status === "succeeded"
1904
+ ? "Builder update pushed"
1905
+ : "Builder update checked",
1906
+ {
1907
+ description: nextReview.result.message,
1436
1908
  },
1437
1909
  );
1438
1910
  } catch (error) {
1911
+ if (
1912
+ !databaseBuilderReviewSessionIsCurrent(
1913
+ builderReviewSessionRef.current,
1914
+ session,
1915
+ )
1916
+ ) {
1917
+ return;
1918
+ }
1919
+ const message =
1920
+ error instanceof Error ? error.message : dbText("tryAgain");
1921
+ setPreparedBuilderReviewConfirmation(null);
1922
+ const ambiguousReview = databaseBuilderReviewAfterExecutionError(
1923
+ preparedReviewForError,
1924
+ message,
1925
+ );
1926
+ if (ambiguousReview) {
1927
+ setBuilderReviewResult(ambiguousReview);
1928
+ setBuilderReviewCheckedAt(new Date().toISOString());
1929
+ if (ambiguousReview.result.status === "running") {
1930
+ setBuilderReviewError(null);
1931
+ toast.info(dbText("working"), {
1932
+ description: ambiguousReview.result.message,
1933
+ });
1934
+ return;
1935
+ }
1936
+ }
1937
+ setBuilderReviewError(message);
1439
1938
  toast.error(dbText("builderUpdateFailed"), {
1440
- description:
1441
- error instanceof Error ? error.message : dbText("tryAgain"),
1939
+ description: message,
1940
+ });
1941
+ }
1942
+ }
1943
+
1944
+ async function handleCancelPreparedBuilderUpdate(changeSetId: string) {
1945
+ const session = builderReviewSessionRef.current;
1946
+ if (
1947
+ !session ||
1948
+ !builderReviewSource ||
1949
+ session.sourceId !== builderReviewSource.id
1950
+ ) {
1951
+ return;
1952
+ }
1953
+ setBuilderReviewError(null);
1954
+ try {
1955
+ const result = await cancelPreparedBuilderUpdate.mutateAsync({
1956
+ documentId: document.id,
1957
+ sourceId: session.sourceId,
1958
+ changeSetId,
1959
+ });
1960
+ if (
1961
+ !databaseBuilderReviewSessionIsCurrent(
1962
+ builderReviewSessionRef.current,
1963
+ session,
1964
+ )
1965
+ ) {
1966
+ return;
1967
+ }
1968
+ toast.success(
1969
+ result.cancellation.status === "already_cancelled"
1970
+ ? dbText("preparedUpdateAlreadyCancelled")
1971
+ : dbText("preparedUpdateCancelled"),
1972
+ );
1973
+ closeBuilderReview(true);
1974
+ } catch (error) {
1975
+ if (
1976
+ !databaseBuilderReviewSessionIsCurrent(
1977
+ builderReviewSessionRef.current,
1978
+ session,
1979
+ )
1980
+ ) {
1981
+ return;
1982
+ }
1983
+ const message =
1984
+ error instanceof Error ? error.message : dbText("tryAgain");
1985
+ setBuilderReviewError(message);
1986
+ toast.error(dbText("cancelPreparedUpdateFailed"), {
1987
+ description: message,
1442
1988
  });
1443
1989
  }
1444
1990
  }
@@ -1810,41 +2356,40 @@ function DatabaseTable({
1810
2356
  onSaveForEveryone={() => void savePersonalQueryForEveryone()}
1811
2357
  />
1812
2358
 
1813
- <BuilderSourceContinuationBar
1814
- source={source}
1815
- canEdit={canEdit}
1816
- pending={refreshSource.isPending}
1817
- clientError={
1818
- source
1819
- ? builderContinuationClientErrorKey ===
1820
- builderSourceContinuationKey(source)
1821
- : false
1822
- }
1823
- progressHighWater={builderProgressHighWater}
1824
- onRetry={() => {
1825
- if (!source) return;
1826
- const continuationKey = builderSourceContinuationKey(source);
1827
- setBuilderContinuationClientErrorKey(null);
1828
- autoContinueBuilderSourceRef.current = null;
1829
- builderContinuationWatchdogRef.current = {
1830
- key: continuationKey,
1831
- refires: 0,
1832
- };
1833
- refreshSource.mutate(
1834
- {
1835
- documentId: document.id,
1836
- sourceId: source.id,
1837
- },
1838
- {
1839
- onError: () => {
1840
- if (continuationKey) {
1841
- setBuilderContinuationClientErrorKey(continuationKey);
1842
- }
1843
- },
1844
- },
1845
- );
1846
- }}
1847
- />
2359
+ {builderSources.map((builderSource) => {
2360
+ const continuationKey = builderSourceContinuationKey(builderSource);
2361
+ return (
2362
+ <BuilderSourceContinuationBar
2363
+ key={builderSource.id}
2364
+ source={builderSource}
2365
+ canEdit={canEdit}
2366
+ pending={
2367
+ sourcePendingOperations.refreshSourceId === builderSource.id
2368
+ }
2369
+ clientError={
2370
+ !!continuationKey &&
2371
+ builderContinuationClientErrorKeys.includes(continuationKey)
2372
+ }
2373
+ progressHighWater={builderProgressHighWater[builderSource.id]}
2374
+ onRetry={() => {
2375
+ if (!continuationKey) return;
2376
+ setBuilderContinuationClientErrorKeys((current) =>
2377
+ current.filter((key) => key !== continuationKey),
2378
+ );
2379
+ databaseRecordBuilderContinuationAttempt(
2380
+ autoContinueBuilderSourceRef.current,
2381
+ continuationKey,
2382
+ );
2383
+ builderContinuationWatchdogRef.current.set(continuationKey, 0);
2384
+ runSourceRefresh(builderSource.id, () =>
2385
+ setBuilderContinuationClientErrorKeys((current) =>
2386
+ addUniqueKey(current, continuationKey),
2387
+ ),
2388
+ );
2389
+ }}
2390
+ />
2391
+ );
2392
+ })}
1848
2393
 
1849
2394
  {activeView.type === "form" ? (
1850
2395
  <DatabaseFormView
@@ -1863,7 +2408,7 @@ function DatabaseTable({
1863
2408
  groupProperty={boardGroupProperty}
1864
2409
  databaseDocumentId={document.id}
1865
2410
  canEdit={canEdit}
1866
- isLoading={isDatabaseInitialLoading}
2411
+ isLoading={isDatabaseViewLoading}
1867
2412
  isCreating={addItem.isPending || setProperty.isPending}
1868
2413
  hasActiveConstraints={!!searchQuery || activeFilters.length > 0}
1869
2414
  isMoving={setProperty.isPending}
@@ -1891,7 +2436,7 @@ function DatabaseTable({
1891
2436
  items={visibleItems}
1892
2437
  databaseDocumentId={document.id}
1893
2438
  canEdit={canEdit}
1894
- isLoading={isDatabaseInitialLoading}
2439
+ isLoading={isDatabaseViewLoading}
1895
2440
  isCreating={addItem.isPending}
1896
2441
  activeFilters={activeFilters}
1897
2442
  hasSearch={!!searchQuery}
@@ -1914,7 +2459,7 @@ function DatabaseTable({
1914
2459
  items={visibleItems}
1915
2460
  databaseDocumentId={document.id}
1916
2461
  canEdit={canEdit}
1917
- isLoading={isDatabaseInitialLoading}
2462
+ isLoading={isDatabaseViewLoading}
1918
2463
  isCreating={addItem.isPending}
1919
2464
  activeFilters={activeFilters}
1920
2465
  hasSearch={!!searchQuery}
@@ -1937,7 +2482,7 @@ function DatabaseTable({
1937
2482
  items={visibleItems}
1938
2483
  databaseDocumentId={document.id}
1939
2484
  canEdit={canEdit}
1940
- isLoading={isDatabaseInitialLoading}
2485
+ isLoading={isDatabaseViewLoading}
1941
2486
  isCreating={addItem.isPending || setProperty.isPending}
1942
2487
  activeFilters={activeFilters}
1943
2488
  hasSearch={!!searchQuery}
@@ -1963,7 +2508,7 @@ function DatabaseTable({
1963
2508
  items={visibleItems}
1964
2509
  databaseDocumentId={document.id}
1965
2510
  canEdit={canEdit}
1966
- isLoading={isDatabaseInitialLoading}
2511
+ isLoading={isDatabaseViewLoading}
1967
2512
  isCreating={addItem.isPending || setProperty.isPending}
1968
2513
  activeFilters={activeFilters}
1969
2514
  hasSearch={!!searchQuery}
@@ -1997,7 +2542,7 @@ function DatabaseTable({
1997
2542
  sources={sources}
1998
2543
  databaseDocumentId={document.id}
1999
2544
  canEdit={canEdit}
2000
- isLoading={isDatabaseInitialLoading}
2545
+ isLoading={isDatabaseViewLoading}
2001
2546
  isCreating={addItem.isPending}
2002
2547
  columnWidths={columnWidths}
2003
2548
  sorts={sorts}
@@ -2044,7 +2589,7 @@ function DatabaseTable({
2044
2589
  />
2045
2590
  )}
2046
2591
 
2047
- {hasMoreItems ? (
2592
+ {hasMoreItems && !isSearchExpansionPending ? (
2048
2593
  <div className="flex items-center justify-center border-t border-border/45 py-3">
2049
2594
  <Button
2050
2595
  type="button"
@@ -2080,6 +2625,7 @@ function DatabaseTable({
2080
2625
  if (!open) {
2081
2626
  setPreviewDocumentId(null);
2082
2627
  setPreviewTitleFocusDocumentId(null);
2628
+ setCreatedPreviewItem(null);
2083
2629
  }
2084
2630
  }}
2085
2631
  onPreviewItem={(item) => {
@@ -2097,6 +2643,7 @@ function DatabaseTable({
2097
2643
  panel={settingsPanel}
2098
2644
  documentId={document.id}
2099
2645
  canEdit={canEdit}
2646
+ canManage={document.canManage === true}
2100
2647
  activeView={activeView}
2101
2648
  properties={orderedProperties}
2102
2649
  items={items}
@@ -2142,15 +2689,11 @@ function DatabaseTable({
2142
2689
  onDisconnectSecondary={(sourceId) =>
2143
2690
  disconnectSource.mutate({ documentId: document.id, sourceId })
2144
2691
  }
2145
- onRefreshSource={(sourceId) =>
2146
- refreshSource.mutate({
2147
- documentId: document.id,
2148
- sourceId,
2149
- })
2150
- }
2151
- onHydrateBuilderBodies={(sourceId) =>
2152
- processBuilderBodies.mutate({ sourceId })
2153
- }
2692
+ onRefreshSource={(sourceId) => {
2693
+ const targetSourceId = sourceId ?? source?.id;
2694
+ if (targetSourceId) runSourceRefresh(targetSourceId);
2695
+ }}
2696
+ onHydrateBuilderBodies={(sourceId) => runBuilderHydration(sourceId)}
2154
2697
  onDisconnectSource={(sourceId) =>
2155
2698
  disconnectSource.mutate(
2156
2699
  {
@@ -2178,30 +2721,29 @@ function DatabaseTable({
2178
2721
  },
2179
2722
  )
2180
2723
  }
2181
- onReviewBuilderUpdate={() => {
2182
- setBuilderReviewResult(null);
2183
- setBuilderReviewCheckedAt(null);
2184
- setBuilderReviewOpen(true);
2724
+ onReviewBuilderUpdate={(sourceId) => {
2725
+ openBuilderReview(sourceId);
2185
2726
  }}
2186
- onSetBuilderLiveWrites={(enabled) =>
2727
+ onSetBuilderLiveWrites={(settings) =>
2187
2728
  setSourceWriteMode.mutate(
2188
2729
  {
2189
2730
  documentId: document.id,
2190
- liveWritesEnabled: enabled,
2191
- allowedWriteModes: enabled ? ["autosave"] : [],
2731
+ sourceId: settings.sourceId,
2732
+ writeMode: settings.writeMode,
2733
+ allowPublicationTransitions:
2734
+ settings.writeMode === "publish_updates" &&
2735
+ settings.allowPublicationTransitions === true,
2192
2736
  },
2193
2737
  {
2194
2738
  onSuccess: () => {
2195
- toast.success(
2196
- enabled
2197
- ? "Builder live writes enabled"
2198
- : "Builder live writes disabled",
2199
- {
2200
- description: enabled
2201
- ? "Only autosave writes to the Agent Native test collection can run."
2202
- : "Push will return to local validation only.",
2203
- },
2204
- );
2739
+ toast.success(dbText("builderWriteModeUpdated"), {
2740
+ description:
2741
+ settings.writeMode === "publish_updates"
2742
+ ? "Approved updates can write through to Builder while preserving publication state."
2743
+ : settings.writeMode === "stage_only"
2744
+ ? "Approved updates will stage Builder autosave revisions."
2745
+ : "Builder writes are disabled for this source.",
2746
+ });
2205
2747
  },
2206
2748
  onError: (error) => {
2207
2749
  toast.error(dbText("builderWriteModeWasNotChanged"), {
@@ -2220,8 +2762,10 @@ function DatabaseTable({
2220
2762
  disconnectSource.isPending ||
2221
2763
  prepareBuilderReview.isPending ||
2222
2764
  executeBuilderExecution.isPending ||
2765
+ cancelPreparedBuilderUpdate.isPending ||
2223
2766
  setSourceWriteMode.isPending
2224
2767
  }
2768
+ sourcePendingOperations={sourcePendingOperations}
2225
2769
  onViewTypeChange={(type) =>
2226
2770
  setViewConfig(updateDatabaseViewType(viewConfig, activeView.id, type))
2227
2771
  }
@@ -2242,14 +2786,36 @@ function DatabaseTable({
2242
2786
  <BuilderSourceReviewDialog
2243
2787
  open={builderReviewOpen}
2244
2788
  review={activeBuilderReview}
2245
- source={source}
2789
+ source={builderReviewSource}
2246
2790
  canEdit={canEdit}
2247
2791
  pending={
2248
- prepareBuilderReview.isPending || executeBuilderExecution.isPending
2792
+ builderReviewPreviewQuery.isFetching ||
2793
+ prepareBuilderReview.isPending ||
2794
+ executeBuilderExecution.isPending ||
2795
+ cancelPreparedBuilderUpdate.isPending
2796
+ }
2797
+ error={
2798
+ builderReviewError ??
2799
+ (builderReviewPreviewQuery.error instanceof Error
2800
+ ? builderReviewPreviewQuery.error.message
2801
+ : null)
2249
2802
  }
2250
2803
  checkedAt={builderReviewCheckedAt}
2251
- onClose={() => setBuilderReviewOpen(false)}
2252
- onValidate={(transitions) => void handleBuilderReviewPush(transitions)}
2804
+ preparedForExecution={preparedBuilderReviewConfirmation !== null}
2805
+ autoSelectReviewRows={selectedItemIds.length > 0}
2806
+ selectionChangeSetIdMap={builderReviewSelectionRemap}
2807
+ onClose={closeBuilderReview}
2808
+ onValidate={(selection) => void handleBuilderReviewPush(selection)}
2809
+ onCancelPrepared={(changeSetId) =>
2810
+ void handleCancelPreparedBuilderUpdate(changeSetId)
2811
+ }
2812
+ onSelectionChange={() => {
2813
+ setBuilderReviewResult(null);
2814
+ setBuilderReviewCheckedAt(null);
2815
+ setBuilderReviewError(null);
2816
+ setPreparedBuilderReviewConfirmation(null);
2817
+ setBuilderReviewSelectionRemap(null);
2818
+ }}
2253
2819
  />
2254
2820
 
2255
2821
  {!isDatabaseInitialLoading ? (
@@ -2541,6 +3107,518 @@ export function databaseBuilderBulkUpdateSource(
2541
3107
  );
2542
3108
  }
2543
3109
 
3110
+ export function databaseAttachedSources(
3111
+ sources: ContentDatabaseSource[] | undefined,
3112
+ primarySource: ContentDatabaseSource | null,
3113
+ ) {
3114
+ const attached = sources ? [...sources] : [];
3115
+ if (
3116
+ primarySource &&
3117
+ !attached.some((source) => source.id === primarySource.id)
3118
+ ) {
3119
+ attached.unshift(primarySource);
3120
+ }
3121
+ return attached;
3122
+ }
3123
+
3124
+ export function databaseAttachedBuilderSources(
3125
+ sources: ContentDatabaseSource[],
3126
+ primarySource: ContentDatabaseSource | null,
3127
+ ) {
3128
+ return databaseAttachedSources(sources, primarySource).filter(
3129
+ (source) => source.sourceType === "builder-cms",
3130
+ );
3131
+ }
3132
+
3133
+ export function databaseAttachedBuilderModelNames(
3134
+ sources: ContentDatabaseSource[],
3135
+ primarySource: ContentDatabaseSource | null,
3136
+ ) {
3137
+ return Array.from(
3138
+ new Set(
3139
+ databaseAttachedBuilderSources(sources, primarySource).map(
3140
+ (source) => source.sourceTable,
3141
+ ),
3142
+ ),
3143
+ );
3144
+ }
3145
+
3146
+ export function databaseAttachedBuilderSource(
3147
+ sources: ContentDatabaseSource[],
3148
+ primarySource: ContentDatabaseSource | null,
3149
+ selection: { sourceId?: string | null; modelName?: string | null },
3150
+ ) {
3151
+ const builderSources = databaseAttachedBuilderSources(sources, primarySource);
3152
+ if (selection.sourceId) {
3153
+ return (
3154
+ builderSources.find((source) => source.id === selection.sourceId) ?? null
3155
+ );
3156
+ }
3157
+ if (selection.modelName) {
3158
+ return (
3159
+ builderSources.find(
3160
+ (source) => source.sourceTable === selection.modelName,
3161
+ ) ?? null
3162
+ );
3163
+ }
3164
+ return null;
3165
+ }
3166
+
3167
+ export function databaseBuilderReviewSource(
3168
+ sources: ContentDatabaseSource[],
3169
+ primarySource: ContentDatabaseSource | null,
3170
+ sourceId: string | null,
3171
+ ) {
3172
+ return sourceId
3173
+ ? databaseAttachedBuilderSource(sources, primarySource, { sourceId })
3174
+ : null;
3175
+ }
3176
+
3177
+ export type BuilderReviewSession = {
3178
+ sourceId: string;
3179
+ generation: number;
3180
+ };
3181
+
3182
+ export type PreparedBuilderReviewConfirmation = BuilderReviewSession & {
3183
+ selectionFingerprint: string;
3184
+ };
3185
+
3186
+ export type PreparedBuilderChangeSetMapping = {
3187
+ requestedChangeSetId: string;
3188
+ preparedChangeSetId: string;
3189
+ };
3190
+
3191
+ export function builderReviewSelectionFingerprint(selection: {
3192
+ changeSetIds: string[];
3193
+ transitions: BuilderReviewPublicationTransitions;
3194
+ }) {
3195
+ return JSON.stringify({
3196
+ changeSetIds: [...selection.changeSetIds].sort(),
3197
+ transitions: Object.entries(selection.transitions)
3198
+ .sort(([left], [right]) => left.localeCompare(right))
3199
+ .map(([changeSetId, transition]) => [changeSetId, transition]),
3200
+ });
3201
+ }
3202
+
3203
+ export function preparedBuilderReviewMatches(
3204
+ confirmation: PreparedBuilderReviewConfirmation | null,
3205
+ session: BuilderReviewSession,
3206
+ selectionFingerprint: string,
3207
+ ) {
3208
+ return (
3209
+ confirmation?.sourceId === session.sourceId &&
3210
+ confirmation.generation === session.generation &&
3211
+ confirmation.selectionFingerprint === selectionFingerprint
3212
+ );
3213
+ }
3214
+
3215
+ export function databaseBuilderReviewSessionIsCurrent(
3216
+ current: BuilderReviewSession | null,
3217
+ captured: BuilderReviewSession,
3218
+ ) {
3219
+ return (
3220
+ current?.sourceId === captured.sourceId &&
3221
+ current.generation === captured.generation
3222
+ );
3223
+ }
3224
+
3225
+ export function databaseBuilderExecutionRequiresReconciliation(
3226
+ message: string,
3227
+ ) {
3228
+ return /reconciliation required|requires reconciliation|remote outcome is unknown|write timed out/i.test(
3229
+ message,
3230
+ );
3231
+ }
3232
+
3233
+ export function databaseBuilderReviewAfterExecutionError(
3234
+ review: ContentDatabaseSourceReviewPayload | null,
3235
+ message: string,
3236
+ ) {
3237
+ if (!review) return null;
3238
+ if (databaseBuilderExecutionRequiresReconciliation(message)) {
3239
+ return {
3240
+ ...review,
3241
+ result: {
3242
+ status: "reconciliation_required" as const,
3243
+ message,
3244
+ },
3245
+ };
3246
+ }
3247
+ if (/execution is already running/i.test(message)) {
3248
+ return {
3249
+ ...review,
3250
+ result: {
3251
+ status: "running" as const,
3252
+ message: dbText("builderPushAlreadyRunning"),
3253
+ },
3254
+ };
3255
+ }
3256
+ return null;
3257
+ }
3258
+
3259
+ export function databaseBuilderReviewBelongsToSource(
3260
+ review: ContentDatabaseSourceReviewPayload,
3261
+ source: ContentDatabaseSource,
3262
+ authoritativeChangeSetIds?: string[],
3263
+ ) {
3264
+ const sourceChangeSetIds = new Set(
3265
+ authoritativeChangeSetIds ??
3266
+ source.changeSets.map((changeSet) => changeSet.id),
3267
+ );
3268
+ return review.rows.every((row) => sourceChangeSetIds.has(row.changeSetId));
3269
+ }
3270
+
3271
+ export function databaseBuilderReviewSelectionIsValid(
3272
+ review: ContentDatabaseSourceReviewPayload,
3273
+ source: ContentDatabaseSource,
3274
+ changeSetIds: string[],
3275
+ requireExactReviewRows = false,
3276
+ authoritativeChangeSetIds?: string[],
3277
+ ) {
3278
+ if (changeSetIds.length === 0) return false;
3279
+ const selectedIds = new Set(changeSetIds);
3280
+ if (selectedIds.size !== changeSetIds.length) return false;
3281
+ if (
3282
+ !databaseBuilderReviewBelongsToSource(
3283
+ review,
3284
+ source,
3285
+ authoritativeChangeSetIds,
3286
+ )
3287
+ ) {
3288
+ return false;
3289
+ }
3290
+ const reviewIds = new Set(review.rows.map((row) => row.changeSetId));
3291
+ if (reviewIds.size !== review.rows.length) return false;
3292
+ if (!changeSetIds.every((changeSetId) => reviewIds.has(changeSetId))) {
3293
+ return false;
3294
+ }
3295
+ return !requireExactReviewRows || reviewIds.size === selectedIds.size;
3296
+ }
3297
+
3298
+ export function databaseBuilderReviewExactSelectionIsValid(
3299
+ review: ContentDatabaseSourceReviewPayload,
3300
+ changeSetIds: string[],
3301
+ ) {
3302
+ if (changeSetIds.length === 0) return false;
3303
+ const selectedIds = new Set(changeSetIds);
3304
+ const reviewIds = new Set(review.rows.map((row) => row.changeSetId));
3305
+ return (
3306
+ selectedIds.size === changeSetIds.length &&
3307
+ reviewIds.size === review.rows.length &&
3308
+ reviewIds.size === selectedIds.size &&
3309
+ changeSetIds.every((changeSetId) => reviewIds.has(changeSetId))
3310
+ );
3311
+ }
3312
+
3313
+ export function databaseBuilderPreparedReviewSelection(
3314
+ review: ContentDatabaseSourceReviewPayload,
3315
+ source: ContentDatabaseSource,
3316
+ requestedChangeSetIds: string[],
3317
+ mappings: PreparedBuilderChangeSetMapping[],
3318
+ ) {
3319
+ if (review.sourceTable !== source.sourceTable) return null;
3320
+ if (mappings.length !== requestedChangeSetIds.length) return null;
3321
+ const requestedIds = new Set(requestedChangeSetIds);
3322
+ const mappedRequestedIds = new Set(
3323
+ mappings.map((mapping) => mapping.requestedChangeSetId),
3324
+ );
3325
+ const preparedChangeSetIds = mappings.map(
3326
+ (mapping) => mapping.preparedChangeSetId,
3327
+ );
3328
+ if (
3329
+ requestedIds.size !== requestedChangeSetIds.length ||
3330
+ mappedRequestedIds.size !== mappings.length ||
3331
+ !requestedChangeSetIds.every((id) => mappedRequestedIds.has(id)) ||
3332
+ !databaseBuilderReviewExactSelectionIsValid(review, preparedChangeSetIds)
3333
+ ) {
3334
+ return null;
3335
+ }
3336
+ return {
3337
+ preparedChangeSetIds,
3338
+ changeSetIdMap: Object.fromEntries(
3339
+ mappings.map((mapping) => [
3340
+ mapping.requestedChangeSetId,
3341
+ mapping.preparedChangeSetId,
3342
+ ]),
3343
+ ),
3344
+ };
3345
+ }
3346
+
3347
+ export function databaseBuilderReviewSelectedChangeSetIds(
3348
+ review: ContentDatabaseSourceReviewPayload,
3349
+ source: ContentDatabaseSource,
3350
+ changeSetIds: string[],
3351
+ authoritativeChangeSetIds?: string[],
3352
+ ) {
3353
+ return databaseBuilderReviewSelectionIsValid(
3354
+ review,
3355
+ source,
3356
+ changeSetIds,
3357
+ false,
3358
+ authoritativeChangeSetIds,
3359
+ )
3360
+ ? [...changeSetIds]
3361
+ : null;
3362
+ }
3363
+
3364
+ export type BuilderSourceProgressHighWater = {
3365
+ fetchedCount: number;
3366
+ hydratedCount: number;
3367
+ rowsComplete: boolean;
3368
+ generation: number;
3369
+ observedFetchedCount: number;
3370
+ observedHydratedCount: number;
3371
+ hydrationTotal: number;
3372
+ lastRefreshedAt: string | null;
3373
+ sourceFetchState: "idle" | "fetching" | "error" | null;
3374
+ };
3375
+
3376
+ export function databaseBuilderProgressHighWater(
3377
+ sources: ContentDatabaseSource[],
3378
+ current: Record<string, BuilderSourceProgressHighWater>,
3379
+ ) {
3380
+ const next: Record<string, BuilderSourceProgressHighWater> = {};
3381
+ for (const source of sources) {
3382
+ const rawFetchedCount =
3383
+ typeof source.metadata.lastReadFetchedEntryCount === "number"
3384
+ ? source.metadata.lastReadFetchedEntryCount
3385
+ : typeof source.metadata.lastReadEntryCount === "number"
3386
+ ? source.metadata.lastReadEntryCount
3387
+ : 0;
3388
+ const fetchedCount =
3389
+ source.metadata.lastReadHasMore === false
3390
+ ? Math.max(rawFetchedCount, source.bodyHydration?.total ?? 0)
3391
+ : rawFetchedCount;
3392
+ const previous = current[source.id];
3393
+ const observedHydratedCount = source.bodyHydration?.hydrated ?? 0;
3394
+ const hydrationTotal = source.bodyHydration?.total ?? 0;
3395
+ const sourceFetchState = source.metadata.sourceFetchState ?? null;
3396
+ const lifecycleTimestampChanged =
3397
+ !!previous &&
3398
+ previous.lastRefreshedAt !== (source.lastRefreshedAt ?? null);
3399
+ const fetchRestarted =
3400
+ !!previous &&
3401
+ previous.rowsComplete &&
3402
+ source.metadata.lastReadHasMore !== false &&
3403
+ (sourceFetchState === "fetching" || lifecycleTimestampChanged);
3404
+ const countsRewound =
3405
+ !!previous && rawFetchedCount < previous.observedFetchedCount;
3406
+ const hydrationQueueRestarted =
3407
+ !!previous &&
3408
+ (observedHydratedCount < previous.observedHydratedCount ||
3409
+ hydrationTotal < previous.hydrationTotal) &&
3410
+ (source.bodyHydration?.pending ?? 0) > 0;
3411
+ const resetLifecycle =
3412
+ fetchRestarted || countsRewound || hydrationQueueRestarted;
3413
+ const baseline = resetLifecycle ? undefined : previous;
3414
+ next[source.id] = {
3415
+ fetchedCount: Math.max(baseline?.fetchedCount ?? 0, fetchedCount),
3416
+ hydratedCount: Math.max(
3417
+ baseline?.hydratedCount ?? 0,
3418
+ observedHydratedCount,
3419
+ ),
3420
+ rowsComplete:
3421
+ baseline?.rowsComplete === true ||
3422
+ source.metadata.lastReadHasMore === false,
3423
+ generation: (previous?.generation ?? 0) + (resetLifecycle ? 1 : 0),
3424
+ observedFetchedCount: rawFetchedCount,
3425
+ observedHydratedCount,
3426
+ hydrationTotal,
3427
+ lastRefreshedAt: source.lastRefreshedAt ?? null,
3428
+ sourceFetchState,
3429
+ };
3430
+ }
3431
+ const currentIds = Object.keys(current);
3432
+ const nextIds = Object.keys(next);
3433
+ return currentIds.length === nextIds.length &&
3434
+ nextIds.every(
3435
+ (sourceId) =>
3436
+ current[sourceId]?.fetchedCount === next[sourceId]?.fetchedCount &&
3437
+ current[sourceId]?.hydratedCount === next[sourceId]?.hydratedCount &&
3438
+ current[sourceId]?.rowsComplete === next[sourceId]?.rowsComplete &&
3439
+ current[sourceId]?.generation === next[sourceId]?.generation &&
3440
+ current[sourceId]?.observedFetchedCount ===
3441
+ next[sourceId]?.observedFetchedCount &&
3442
+ current[sourceId]?.observedHydratedCount ===
3443
+ next[sourceId]?.observedHydratedCount &&
3444
+ current[sourceId]?.hydrationTotal === next[sourceId]?.hydrationTotal &&
3445
+ current[sourceId]?.lastRefreshedAt ===
3446
+ next[sourceId]?.lastRefreshedAt &&
3447
+ current[sourceId]?.sourceFetchState ===
3448
+ next[sourceId]?.sourceFetchState,
3449
+ )
3450
+ ? current
3451
+ : next;
3452
+ }
3453
+
3454
+ type BuilderSourcePumpSelection = {
3455
+ attemptedKeys: ReadonlySet<string>;
3456
+ errorKeys: ReadonlySet<string>;
3457
+ };
3458
+
3459
+ export function databaseRecordBuilderContinuationAttempt(
3460
+ attemptedKeys: Set<string>,
3461
+ continuationKey: string,
3462
+ ) {
3463
+ attemptedKeys.add(continuationKey);
3464
+ }
3465
+
3466
+ export function databaseNextBuilderContinuationSource(
3467
+ sources: ContentDatabaseSource[],
3468
+ selection: BuilderSourcePumpSelection,
3469
+ ) {
3470
+ return (
3471
+ sources.find((source) => {
3472
+ const key = builderSourceContinuationKey(source);
3473
+ return (
3474
+ source.sourceType === "builder-cms" &&
3475
+ !sourceAddsDetails(source) &&
3476
+ builderSourceRowFetchStatus(source) === "fetching" &&
3477
+ source.metadata.lastReadHasMore === true &&
3478
+ !!key &&
3479
+ !selection.attemptedKeys.has(key) &&
3480
+ !selection.errorKeys.has(key)
3481
+ );
3482
+ }) ?? null
3483
+ );
3484
+ }
3485
+
3486
+ export function databaseNextBuilderContinuationWatchdogSource(
3487
+ sources: ContentDatabaseSource[],
3488
+ selection: BuilderSourcePumpSelection & {
3489
+ refiresByKey: ReadonlyMap<string, number>;
3490
+ },
3491
+ ) {
3492
+ return (
3493
+ sources
3494
+ .filter((source) => {
3495
+ const key = builderSourceContinuationKey(source);
3496
+ return (
3497
+ source.sourceType === "builder-cms" &&
3498
+ !sourceAddsDetails(source) &&
3499
+ builderSourceRowFetchStatus(source) === "fetching" &&
3500
+ source.metadata.lastReadHasMore === true &&
3501
+ !!key &&
3502
+ selection.attemptedKeys.has(key) &&
3503
+ !selection.errorKeys.has(key)
3504
+ );
3505
+ })
3506
+ .sort((left, right) => {
3507
+ const leftKey = builderSourceContinuationKey(left) ?? "";
3508
+ const rightKey = builderSourceContinuationKey(right) ?? "";
3509
+ return (
3510
+ (selection.refiresByKey.get(leftKey) ?? 0) -
3511
+ (selection.refiresByKey.get(rightKey) ?? 0)
3512
+ );
3513
+ })[0] ?? null
3514
+ );
3515
+ }
3516
+
3517
+ export function databaseNextBuilderHydrationSource(
3518
+ sources: ContentDatabaseSource[],
3519
+ selection: BuilderSourcePumpSelection,
3520
+ ) {
3521
+ return (
3522
+ sources.find((source) => {
3523
+ const key = builderBodyHydrationPumpKey(source);
3524
+ return (
3525
+ !!key &&
3526
+ !selection.attemptedKeys.has(key) &&
3527
+ !selection.errorKeys.has(key) &&
3528
+ shouldPumpBuilderBodyHydration(source, false, null)
3529
+ );
3530
+ }) ?? null
3531
+ );
3532
+ }
3533
+
3534
+ export type DatabaseSourcePendingOperation =
3535
+ | "changeRole"
3536
+ | "refresh"
3537
+ | "hydration"
3538
+ | "disconnect"
3539
+ | "review"
3540
+ | "execute"
3541
+ | "writeMode";
3542
+
3543
+ export type DatabaseSourcePendingOperations = {
3544
+ attach: boolean;
3545
+ changeRole: boolean;
3546
+ refresh: boolean;
3547
+ hydration: boolean;
3548
+ disconnect: boolean;
3549
+ review: boolean;
3550
+ execute: boolean;
3551
+ writeMode: boolean;
3552
+ changeRoleSourceId: string | null;
3553
+ refreshSourceId: string | null;
3554
+ hydrationSourceId: string | null;
3555
+ disconnectSourceId: string | null;
3556
+ reviewSourceId: string | null;
3557
+ executeSourceId: string | null;
3558
+ writeModeSourceId: string | null;
3559
+ };
3560
+
3561
+ export function acquireDatabaseSourceOperation(
3562
+ lock: { current: string | null },
3563
+ sourceId: string,
3564
+ ) {
3565
+ if (lock.current !== null) return false;
3566
+ lock.current = sourceId;
3567
+ return true;
3568
+ }
3569
+
3570
+ export function releaseDatabaseSourceOperation(
3571
+ lock: { current: string | null },
3572
+ sourceId: string,
3573
+ ) {
3574
+ if (lock.current === sourceId) lock.current = null;
3575
+ }
3576
+
3577
+ export function pendingMutationSourceId(
3578
+ isPending: boolean,
3579
+ variables: { sourceId?: string } | undefined,
3580
+ ) {
3581
+ return isPending ? (variables?.sourceId ?? null) : null;
3582
+ }
3583
+
3584
+ export function databaseSourceOperationIsPending(
3585
+ pending: DatabaseSourcePendingOperations,
3586
+ sourceId: string,
3587
+ operations: DatabaseSourcePendingOperation[],
3588
+ ) {
3589
+ const sourceIds: Record<DatabaseSourcePendingOperation, string | null> = {
3590
+ changeRole: pending.changeRoleSourceId,
3591
+ refresh: pending.refreshSourceId,
3592
+ hydration: pending.hydrationSourceId,
3593
+ disconnect: pending.disconnectSourceId,
3594
+ review: pending.reviewSourceId,
3595
+ execute: pending.executeSourceId,
3596
+ writeMode: pending.writeModeSourceId,
3597
+ };
3598
+ return operations.some((operation) => sourceIds[operation] === sourceId);
3599
+ }
3600
+
3601
+ export function databaseBuilderWriteModeOperationPending(
3602
+ pending: DatabaseSourcePendingOperations,
3603
+ sourceId: string,
3604
+ ) {
3605
+ return (
3606
+ pending.writeMode ||
3607
+ databaseSourceOperationIsPending(pending, sourceId, [
3608
+ "changeRole",
3609
+ "refresh",
3610
+ "disconnect",
3611
+ "review",
3612
+ "execute",
3613
+ "writeMode",
3614
+ ])
3615
+ );
3616
+ }
3617
+
3618
+ function addUniqueKey(current: string[], key: string) {
3619
+ return current.includes(key) ? current : [...current, key];
3620
+ }
3621
+
2544
3622
  export function databaseBulkScalarInputState(
2545
3623
  type: DocumentPropertyType,
2546
3624
  input: string,
@@ -2799,19 +3877,44 @@ function DatabaseItemPreview({
2799
3877
  const deleteDocument = useDeleteDocument();
2800
3878
  const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId);
2801
3879
  const { data: document, isLoading } = useDocument(item.document.id);
3880
+ const { data: persistedPreviewDraft } = usePreviewDocumentDraft(
3881
+ item.document.id,
3882
+ );
3883
+ const updatePreviewDraft = useUpdatePreviewDocumentDraft();
2802
3884
  const previewDocument = document ?? item.document;
2803
3885
  const previewTitle = databaseItemPreviewTitle(item);
3886
+ const [bodyDraftConflict, setBodyDraftConflict] = useState<{
3887
+ documentId: string;
3888
+ serverPayload: {
3889
+ title: string;
3890
+ content: string;
3891
+ loadedUpdatedAt?: string;
3892
+ loadedContentWasEmpty?: boolean;
3893
+ };
3894
+ } | null>(null);
3895
+ const activeBodyDraftConflict =
3896
+ bodyDraftConflict?.documentId === item.document.id
3897
+ ? bodyDraftConflict
3898
+ : null;
2804
3899
  const canEdit = document?.canEdit ?? item.document.canEdit ?? true;
2805
3900
  const canManage = document?.canManage ?? item.document.canManage ?? false;
2806
- const bodyHydrationPending = previewBodyHydrationIsPending({
2807
- item,
2808
- document,
2809
- });
3901
+ const sourceBackedPreview = Boolean(
3902
+ item.bodyHydration ||
3903
+ item.document.databaseMembership?.sourceId ||
3904
+ document?.databaseMembership?.sourceId,
3905
+ );
3906
+ const bodyHydrationPending =
3907
+ (sourceBackedPreview && (isLoading || !document)) ||
3908
+ previewBodyHydrationIsPending({
3909
+ item,
3910
+ document,
3911
+ });
2810
3912
  const bodyHydrationError = previewBodyHydrationIsTerminalError({
2811
3913
  item,
2812
3914
  document,
2813
3915
  });
2814
- const previewCanEdit = canEdit && !bodyHydrationPending;
3916
+ const previewCanEdit =
3917
+ canEdit && !bodyHydrationPending && !activeBodyDraftConflict;
2815
3918
  const location = useLocation();
2816
3919
  // Seed the displayed title/content from a RETAINED dirty controller's pending
2817
3920
  // edit if one exists for this doc (reopen-before-evict), so an unsaved peek
@@ -2856,16 +3959,237 @@ function DatabaseItemPreview({
2856
3959
  queryClientRef.current = queryClient;
2857
3960
  const bodyHydrationPendingRef = useRef(bodyHydrationPending);
2858
3961
  bodyHydrationPendingRef.current = bodyHydrationPending;
3962
+ const draftVersionsRef = useRef<Map<string, number | null>>(new Map());
3963
+ const draftWriteChainsRef = useRef<Map<string, Promise<void>>>(new Map());
3964
+ const draftSaveTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
3965
+ new Map(),
3966
+ );
3967
+ const updatePreviewDraftRef = useRef(updatePreviewDraft);
3968
+ updatePreviewDraftRef.current = updatePreviewDraft;
2859
3969
  // Doc ids that have been deleted in this peek's lifetime. A pending save must
2860
3970
  // never resurrect a deleted document, so dispatch is suppressed for these.
2861
3971
  const deletedIdsRef = useRef<Set<string>>(new Set());
2862
3972
 
2863
3973
  const documentId = item.document.id;
2864
3974
 
2865
- // Build the factory for THIS row's controller. It closes over the stable
2866
- // component-scoped refs (updateDocument, queryClient, deletedIds), so the
2867
- // freshest mutation impl is always used while the controller's save TARGET
2868
- // (`documentId`) is fixed by the registry key.
3975
+ function enqueueDraftWrite(
3976
+ controller: ReturnType<typeof createPreviewDocumentSaveController>,
3977
+ operation: "upsert" | "delete",
3978
+ expectedPayload?: { title: string; content: string },
3979
+ allowCreateRetry = true,
3980
+ ) {
3981
+ const snapshot = controller.draftSnapshot();
3982
+ const previous =
3983
+ draftWriteChainsRef.current.get(documentId) ?? Promise.resolve();
3984
+ const next = previous
3985
+ .catch(() => undefined)
3986
+ .then(async () => {
3987
+ const expectedVersion =
3988
+ draftVersionsRef.current.get(documentId) ?? null;
3989
+ if (operation === "delete") {
3990
+ if (expectedVersion === null) return;
3991
+ if (!expectedPayload) return;
3992
+ const result = await updatePreviewDraftRef.current.mutateAsync({
3993
+ operation: "delete",
3994
+ documentId,
3995
+ expectedVersion,
3996
+ expectedTitle: expectedPayload.title,
3997
+ expectedContent: expectedPayload.content,
3998
+ });
3999
+ if (result.status === "deleted")
4000
+ draftVersionsRef.current.set(documentId, null);
4001
+ else if (result.draft) {
4002
+ draftVersionsRef.current.set(documentId, result.draft.version);
4003
+ if (
4004
+ previewDraftNeedsConflict({
4005
+ returnedDraft: result.draft,
4006
+ pending: controller.pending,
4007
+ })
4008
+ ) {
4009
+ controller.notifyDraftConflict({
4010
+ lastSaved: snapshot.lastSaved,
4011
+ pending: {
4012
+ title: result.draft.title,
4013
+ content: result.draft.content,
4014
+ loadedUpdatedAt:
4015
+ result.draft.baseDocumentUpdatedAt ?? undefined,
4016
+ loadedContentWasEmpty:
4017
+ result.draft.loadedContentWasEmpty === 1,
4018
+ },
4019
+ deferredReason: "conflict",
4020
+ });
4021
+ }
4022
+ } else {
4023
+ // Another tab already removed the row. Treat the stale delete as
4024
+ // converged instead of retaining a version that can never match.
4025
+ draftVersionsRef.current.set(documentId, null);
4026
+ }
4027
+ return;
4028
+ }
4029
+ const result = await updatePreviewDraftRef.current.mutateAsync({
4030
+ operation: "upsert",
4031
+ documentId,
4032
+ expectedVersion,
4033
+ draft: {
4034
+ title: snapshot.pending.title,
4035
+ content: snapshot.pending.content,
4036
+ baseDocumentUpdatedAt: snapshot.pending.loadedUpdatedAt ?? null,
4037
+ loadedContentWasEmpty:
4038
+ snapshot.pending.loadedContentWasEmpty === true,
4039
+ deferredReason: snapshot.deferredReason,
4040
+ },
4041
+ });
4042
+ if (result.status === "saved" && result.draft) {
4043
+ draftVersionsRef.current.set(documentId, result.draft.version);
4044
+ } else if (result.draft) {
4045
+ draftVersionsRef.current.set(documentId, result.draft.version);
4046
+ if (
4047
+ previewDraftNeedsConflict({
4048
+ returnedDraft: result.draft,
4049
+ pending: controller.pending,
4050
+ })
4051
+ ) {
4052
+ controller.notifyDraftConflict({
4053
+ lastSaved: snapshot.lastSaved,
4054
+ pending: {
4055
+ title: result.draft.title,
4056
+ content: result.draft.content,
4057
+ loadedUpdatedAt:
4058
+ result.draft.baseDocumentUpdatedAt ?? undefined,
4059
+ loadedContentWasEmpty: result.draft.loadedContentWasEmpty === 1,
4060
+ },
4061
+ deferredReason: "conflict",
4062
+ });
4063
+ }
4064
+ } else {
4065
+ // A competing tab deleted the version we tried to update. Reset to
4066
+ // create mode and enqueue the latest pending payload once; keeping
4067
+ // the stale version would make every later upsert conflict forever.
4068
+ draftVersionsRef.current.set(documentId, null);
4069
+ const recovery = previewDraftMissingCasRecovery({
4070
+ operation: "upsert",
4071
+ allowCreateRetry,
4072
+ });
4073
+ if (recovery === "retry-create") {
4074
+ enqueueDraftWrite(controller, "upsert", undefined, false);
4075
+ } else {
4076
+ toast.error(dbText("failedToSavePagePreview"), {
4077
+ description: dbText("somethingWentWrong"),
4078
+ });
4079
+ }
4080
+ }
4081
+ });
4082
+ draftWriteChainsRef.current.set(
4083
+ documentId,
4084
+ next.catch((error) => {
4085
+ toast.error(dbText("failedToSavePagePreview"), {
4086
+ description:
4087
+ error instanceof Error
4088
+ ? error.message
4089
+ : dbText("somethingWentWrong"),
4090
+ });
4091
+ }),
4092
+ );
4093
+ }
4094
+
4095
+ function scheduleDraftWrite(
4096
+ controller: ReturnType<typeof createPreviewDocumentSaveController>,
4097
+ ) {
4098
+ const existing = draftSaveTimersRef.current.get(documentId);
4099
+ if (existing) clearTimeout(existing);
4100
+ const timer = setTimeout(() => {
4101
+ draftSaveTimersRef.current.delete(documentId);
4102
+ enqueueDraftWrite(controller, "upsert");
4103
+ }, 250);
4104
+ draftSaveTimersRef.current.set(documentId, timer);
4105
+ }
4106
+
4107
+ // Adapters are replaced whenever a document is acquired. A controller can
4108
+ // outlive one preview mount while its final flush settles, but it must never
4109
+ // retain that old mount's hydration refs, mutation callback, or conflict UI.
4110
+ const makeSaveAdapter = (): PreviewDocumentSaveAdapter => ({
4111
+ save: (id, payload, baseline) =>
4112
+ new Promise((resolve, reject) => {
4113
+ // A just-deleted doc must not be re-dispatched (resurrection guard).
4114
+ if (deletedIdsRef.current.has(id)) {
4115
+ resolve(undefined);
4116
+ return;
4117
+ }
4118
+ const titleChanged = payload.title !== baseline?.title;
4119
+ const contentChanged = payload.content !== baseline?.content;
4120
+ if (bodyHydrationPendingRef.current && contentChanged) {
4121
+ resolve(deferredPreviewDocumentSave());
4122
+ return;
4123
+ }
4124
+ updateDocumentRef.current.mutate(
4125
+ {
4126
+ id,
4127
+ ...(titleChanged ? { title: payload.title } : {}),
4128
+ ...(contentChanged
4129
+ ? {
4130
+ content: payload.content,
4131
+ loadedUpdatedAt: payload.loadedUpdatedAt,
4132
+ loadedContentWasEmpty: payload.loadedContentWasEmpty,
4133
+ }
4134
+ : {}),
4135
+ ...(contentChanged && payload.loadedUpdatedAt
4136
+ ? { baseUpdatedAt: payload.loadedUpdatedAt }
4137
+ : {}),
4138
+ },
4139
+ {
4140
+ onSuccess: (result) => {
4141
+ if (isDocumentUpdateConflict(result)) {
4142
+ resolve(
4143
+ deferredPreviewDocumentSave("conflict", {
4144
+ lastSaved: baseline ?? payload,
4145
+ pending: {
4146
+ title: result.document.title,
4147
+ content: result.document.content,
4148
+ loadedUpdatedAt: result.document.updatedAt,
4149
+ loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
4150
+ result.document.content,
4151
+ ),
4152
+ },
4153
+ deferredReason: "conflict",
4154
+ }),
4155
+ );
4156
+ return;
4157
+ }
4158
+ resolve(undefined);
4159
+ },
4160
+ onError: reject,
4161
+ },
4162
+ );
4163
+ }),
4164
+ onSaved: (persistedPayload) => {
4165
+ const controller = peekPreviewDocumentSaveController(documentId);
4166
+ if (controller) enqueueDraftWrite(controller, "delete", persistedPayload);
4167
+ void queryClientRef.current.invalidateQueries({
4168
+ queryKey: contentDatabaseQueryKey(databaseDocumentId),
4169
+ });
4170
+ void queryClientRef.current.invalidateQueries({
4171
+ queryKey: ["action", "list-documents"],
4172
+ });
4173
+ },
4174
+ onError: (err) => {
4175
+ toast.error(dbText("failedToSavePagePreview"), {
4176
+ description:
4177
+ err instanceof Error ? err.message : dbText("somethingWentWrong"),
4178
+ });
4179
+ },
4180
+ onDraftConflict: (snapshot) => {
4181
+ setBodyDraftConflict({
4182
+ documentId,
4183
+ serverPayload: {
4184
+ title: snapshot.pending.title,
4185
+ content: snapshot.pending.content,
4186
+ loadedUpdatedAt: snapshot.pending.loadedUpdatedAt,
4187
+ loadedContentWasEmpty: snapshot.pending.loadedContentWasEmpty,
4188
+ },
4189
+ });
4190
+ },
4191
+ });
4192
+
2869
4193
  const makeController = () =>
2870
4194
  createPreviewDocumentSaveController({
2871
4195
  documentId,
@@ -2877,42 +4201,7 @@ function DatabaseItemPreview({
2877
4201
  item.document.content,
2878
4202
  ),
2879
4203
  },
2880
- save: (id, payload) =>
2881
- new Promise((resolve, reject) => {
2882
- // A just-deleted doc must not be re-dispatched (resurrection guard).
2883
- if (deletedIdsRef.current.has(id)) {
2884
- resolve(undefined);
2885
- return;
2886
- }
2887
- if (bodyHydrationPendingRef.current) {
2888
- resolve(skippedPreviewDocumentSave());
2889
- return;
2890
- }
2891
- updateDocumentRef.current.mutate(
2892
- {
2893
- id,
2894
- title: payload.title,
2895
- content: payload.content,
2896
- loadedUpdatedAt: payload.loadedUpdatedAt,
2897
- loadedContentWasEmpty: payload.loadedContentWasEmpty,
2898
- },
2899
- { onSuccess: () => resolve(undefined), onError: reject },
2900
- );
2901
- }),
2902
- onSaved: () => {
2903
- void queryClientRef.current.invalidateQueries({
2904
- queryKey: contentDatabaseQueryKey(databaseDocumentId),
2905
- });
2906
- void queryClientRef.current.invalidateQueries({
2907
- queryKey: ["action", "list-documents"],
2908
- });
2909
- },
2910
- onError: (err) => {
2911
- toast.error(dbText("failedToSavePagePreview"), {
2912
- description:
2913
- err instanceof Error ? err.message : dbText("somethingWentWrong"),
2914
- });
2915
- },
4204
+ ...makeSaveAdapter(),
2916
4205
  });
2917
4206
 
2918
4207
  // Acquire the controller for the current row, and release it on row-switch /
@@ -2946,8 +4235,21 @@ function DatabaseItemPreview({
2946
4235
  saveControllerRef.current = acquirePreviewDocumentSaveController(
2947
4236
  documentId,
2948
4237
  makeController,
4238
+ (controller) => controller.replaceSaveAdapter(makeSaveAdapter()),
2949
4239
  );
2950
4240
  return () => {
4241
+ const controller = saveControllerRef.current;
4242
+ const draftTimer = draftSaveTimersRef.current.get(documentId);
4243
+ if (draftTimer) {
4244
+ clearTimeout(draftTimer);
4245
+ draftSaveTimersRef.current.delete(documentId);
4246
+ }
4247
+ if (
4248
+ controller &&
4249
+ !previewPayloadsEqual(controller.pending, controller.lastSaved)
4250
+ ) {
4251
+ enqueueDraftWrite(controller, "upsert");
4252
+ }
2951
4253
  saveControllerRef.current = null;
2952
4254
  releasePreviewDocumentSaveController(documentId);
2953
4255
  };
@@ -2957,6 +4259,58 @@ function DatabaseItemPreview({
2957
4259
  // eslint-disable-next-line react-hooks/exhaustive-deps
2958
4260
  }, [documentId]);
2959
4261
 
4262
+ useEffect(() => {
4263
+ const draft = persistedPreviewDraft?.draft;
4264
+ if (!draft) {
4265
+ if (!draftVersionsRef.current.has(documentId)) {
4266
+ draftVersionsRef.current.set(documentId, null);
4267
+ }
4268
+ return;
4269
+ }
4270
+ draftVersionsRef.current.set(documentId, draft.version);
4271
+ const controller = peekPreviewDocumentSaveController(documentId);
4272
+ if (!controller) return;
4273
+ const controllerDirty = !previewPayloadsEqual(
4274
+ controller.pending,
4275
+ controller.lastSaved,
4276
+ );
4277
+ const snapshot = {
4278
+ lastSaved: controller.lastSaved,
4279
+ pending: {
4280
+ title: draft.title,
4281
+ content: draft.content,
4282
+ loadedUpdatedAt: draft.baseDocumentUpdatedAt ?? undefined,
4283
+ loadedContentWasEmpty: draft.loadedContentWasEmpty === 1,
4284
+ },
4285
+ deferredReason:
4286
+ draft.deferredReason === "hydration" ||
4287
+ draft.deferredReason === "conflict"
4288
+ ? draft.deferredReason
4289
+ : null,
4290
+ } as const;
4291
+ if (controllerDirty) {
4292
+ if (
4293
+ previewDraftNeedsConflict({
4294
+ returnedDraft: draft,
4295
+ pending: controller.pending,
4296
+ })
4297
+ ) {
4298
+ controller.notifyDraftConflict(snapshot);
4299
+ } else {
4300
+ setBodyDraftConflict((current) =>
4301
+ current?.documentId === documentId ? null : current,
4302
+ );
4303
+ }
4304
+ return;
4305
+ }
4306
+ controller.restoreDraft(snapshot);
4307
+ setLocalTitle(snapshot.pending.title);
4308
+ setLocalContent(snapshot.pending.content);
4309
+ if (snapshot.deferredReason === "conflict") {
4310
+ controller.notifyDraftConflict(snapshot);
4311
+ }
4312
+ }, [documentId, persistedPreviewDraft?.draft]);
4313
+
2960
4314
  // Sync displayed state to the current row, and adopt fresh server content
2961
4315
  // (e.g. an agent edit) as the controller's new confirmed baseline. mark()
2962
4316
  // touches only THIS row's controller — never another row's — because the
@@ -2990,12 +4344,31 @@ function DatabaseItemPreview({
2990
4344
  controller.lastSaved.loadedContentWasEmpty === true &&
2991
4345
  isEffectivelyEmptyDocumentContent(controller.pending.content) &&
2992
4346
  !isEffectivelyEmptyDocumentContent(nextContent);
4347
+ const hydratedBodyConflictsWithDraft =
4348
+ !!controller &&
4349
+ dirty &&
4350
+ (controller.deferredReason === "conflict" ||
4351
+ previewDraftConflictsWithHydratedBody({
4352
+ loadedContent: controller.lastSaved.content,
4353
+ loadedUpdatedAt: controller.lastSaved.loadedUpdatedAt,
4354
+ loadedContentWasEmpty: controller.lastSaved.loadedContentWasEmpty,
4355
+ pendingContent: controller.pending.content,
4356
+ hydratedContent: nextContent,
4357
+ hydratedUpdatedAt: nextLoadedUpdatedAt,
4358
+ }));
2993
4359
  // Only adopt the server's title/content — into BOTH the displayed editor
2994
4360
  // state and the controller baseline — when the user hasn't typed something
2995
4361
  // newer on this row. If a dirty in-progress edit exists, preserve it: don't
2996
4362
  // clobber the visible text (the controller already holds the unsaved edit,
2997
4363
  // so nothing is lost, but the editor must keep showing what the user typed).
2998
- if (staleEmptyPendingOverFreshServer || (!dirty && !savedAheadOfServer)) {
4364
+ if (hydratedBodyConflictsWithDraft) {
4365
+ setBodyDraftConflict({ documentId, serverPayload });
4366
+ setLocalTitle(controller.pending.title);
4367
+ setLocalContent(controller.pending.content);
4368
+ } else if (
4369
+ staleEmptyPendingOverFreshServer ||
4370
+ (!dirty && !savedAheadOfServer)
4371
+ ) {
2999
4372
  setLocalTitle(nextTitle);
3000
4373
  setLocalContent(nextContent);
3001
4374
  controller?.mark(serverPayload);
@@ -3018,6 +4391,36 @@ function DatabaseItemPreview({
3018
4391
  item.document.updatedAt,
3019
4392
  ]);
3020
4393
 
4394
+ // If hydration began after a keystroke, the controller deferred that save
4395
+ // without discarding it. Retry once the authoritative document is ready —
4396
+ // unless a non-empty Builder body arrived over an empty baseline. In that
4397
+ // conflict case the draft remains dirty in the registry instead of silently
4398
+ // overwriting either side; the update action's CAS is a final fail-closed
4399
+ // guard if the source changed between this check and the write.
4400
+ useEffect(() => {
4401
+ if (bodyHydrationPending || !document) return;
4402
+ const controller = peekPreviewDocumentSaveController(documentId);
4403
+ if (!controller || controller.deferredReason !== "hydration") return;
4404
+ if (
4405
+ previewDraftConflictsWithHydratedBody({
4406
+ loadedContent: controller.lastSaved.content,
4407
+ loadedUpdatedAt: controller.lastSaved.loadedUpdatedAt,
4408
+ loadedContentWasEmpty: controller.lastSaved.loadedContentWasEmpty,
4409
+ pendingContent: controller.pending.content,
4410
+ hydratedContent: document.content,
4411
+ hydratedUpdatedAt: document.updatedAt,
4412
+ })
4413
+ ) {
4414
+ return;
4415
+ }
4416
+ void controller.flush();
4417
+ }, [
4418
+ bodyHydrationPending,
4419
+ document?.content,
4420
+ document?.updatedAt,
4421
+ documentId,
4422
+ ]);
4423
+
3021
4424
  useEffect(() => {
3022
4425
  if (!focusTitle || !previewCanEdit || isLoading || !document) return;
3023
4426
 
@@ -3033,7 +4436,9 @@ function DatabaseItemPreview({
3033
4436
  function handleTitleChange(nextTitle: string) {
3034
4437
  setLocalTitle(nextTitle);
3035
4438
  if (!previewCanEdit || !document) return;
3036
- saveControllerRef.current?.changeTitle(nextTitle);
4439
+ const controller = saveControllerRef.current;
4440
+ controller?.changeTitle(nextTitle);
4441
+ if (controller) scheduleDraftWrite(controller);
3037
4442
  }
3038
4443
 
3039
4444
  function handleContentChange(nextContent: string) {
@@ -3048,7 +4453,31 @@ function DatabaseItemPreview({
3048
4453
  }
3049
4454
  setLocalContent(nextContent);
3050
4455
  if (!previewCanEdit || !document) return;
3051
- saveControllerRef.current?.changeContent(nextContent);
4456
+ const controller = saveControllerRef.current;
4457
+ controller?.changeContent(nextContent);
4458
+ if (controller) scheduleDraftWrite(controller);
4459
+ }
4460
+
4461
+ function keepLocalBodyDraft() {
4462
+ if (!activeBodyDraftConflict) return;
4463
+ const controller = peekPreviewDocumentSaveController(documentId);
4464
+ if (!controller) return;
4465
+ controller.rebasePending(activeBodyDraftConflict.serverPayload);
4466
+ setBodyDraftConflict(null);
4467
+ void controller.flush();
4468
+ }
4469
+
4470
+ function reloadBuilderBody() {
4471
+ if (!activeBodyDraftConflict) return;
4472
+ const controller = peekPreviewDocumentSaveController(documentId);
4473
+ if (!controller) return;
4474
+ const serverPayload = activeBodyDraftConflict.serverPayload;
4475
+ const discardedPayload = controller.pending;
4476
+ controller.mark(serverPayload);
4477
+ enqueueDraftWrite(controller, "delete", discardedPayload);
4478
+ setLocalTitle(serverPayload.title);
4479
+ setLocalContent(serverPayload.content);
4480
+ setBodyDraftConflict(null);
3052
4481
  }
3053
4482
 
3054
4483
  function handleIconChange(nextIcon: string | null) {
@@ -3343,6 +4772,37 @@ function DatabaseItemPreview({
3343
4772
 
3344
4773
  return (
3345
4774
  <div className="grid gap-4">
4775
+ {activeBodyDraftConflict ? (
4776
+ <div
4777
+ role="status"
4778
+ className="rounded-lg border border-amber-500/35 bg-amber-500/10 px-4 py-4 text-sm"
4779
+ >
4780
+ <div className="flex items-center gap-2 font-medium text-foreground">
4781
+ <IconLock className="size-4 text-amber-600 dark:text-amber-400" />
4782
+ {dbText("builderDraftConflictTitle")}
4783
+ </div>
4784
+ <p className="mt-2 max-w-2xl leading-6 text-muted-foreground">
4785
+ {dbText("builderDraftConflictDescription")}
4786
+ </p>
4787
+ <div className="mt-3 flex flex-wrap gap-2">
4788
+ <Button
4789
+ type="button"
4790
+ size="sm"
4791
+ onClick={keepLocalBodyDraft}
4792
+ >
4793
+ {dbText("keepLocalDraft")}
4794
+ </Button>
4795
+ <Button
4796
+ type="button"
4797
+ size="sm"
4798
+ variant="outline"
4799
+ onClick={reloadBuilderBody}
4800
+ >
4801
+ {dbText("reloadBuilderBody")}
4802
+ </Button>
4803
+ </div>
4804
+ </div>
4805
+ ) : null}
3346
4806
  {bodyHydrationError ? (
3347
4807
  <BuilderBodySyncingNotice
3348
4808
  title={dbText("builderBodySyncFailedNotice")}
@@ -5407,7 +6867,12 @@ type PendingSourceCandidate = {
5407
6867
  type SourceNavStep =
5408
6868
  | { kind: "provider"; providerId: "builder" }
5409
6869
  | { kind: "space"; spaceId: string; spaceName: string }
5410
- | { kind: "model"; model: BuilderCmsModelSummary }
6870
+ | {
6871
+ kind: "model";
6872
+ model?: BuilderCmsModelSummary;
6873
+ sourceId?: string;
6874
+ sourceName?: string;
6875
+ }
5411
6876
  | { kind: "addSource" }
5412
6877
  | { kind: "secondarySource"; sourceId: string; sourceName: string }
5413
6878
  | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
@@ -5422,7 +6887,7 @@ function sourceNavTitle(stack: SourceNavStep[]): string {
5422
6887
  if (top.kind === "secondarySource") return top.sourceName;
5423
6888
  if (top.kind === "keyConfirm") return dbText("matchExistingItemsToDetails");
5424
6889
  if (top.kind === "fieldPicker") return dbText("chooseFields");
5425
- return top.model.displayName;
6890
+ return top.model?.displayName ?? top.sourceName ?? "Builder";
5426
6891
  }
5427
6892
 
5428
6893
  // The Builder "B" brand mark (first glyph of the wordmark), drawn with
@@ -5470,6 +6935,7 @@ function DatabaseSettingsPanelSheet({
5470
6935
  panel,
5471
6936
  documentId,
5472
6937
  canEdit,
6938
+ canManage,
5473
6939
  activeView,
5474
6940
  properties,
5475
6941
  items,
@@ -5489,6 +6955,7 @@ function DatabaseSettingsPanelSheet({
5489
6955
  onReviewBuilderUpdate,
5490
6956
  onSetBuilderLiveWrites,
5491
6957
  sourceActionPending,
6958
+ sourcePendingOperations,
5492
6959
  onViewTypeChange,
5493
6960
  onWrapCellsChange,
5494
6961
  onOpenPagesInChange,
@@ -5503,6 +6970,7 @@ function DatabaseSettingsPanelSheet({
5503
6970
  panel: DatabaseSettingsPanel;
5504
6971
  documentId: string;
5505
6972
  canEdit: boolean;
6973
+ canManage: boolean;
5506
6974
  activeView: ContentDatabaseView;
5507
6975
  properties: DocumentProperty[];
5508
6976
  items: ContentDatabaseItem[];
@@ -5529,9 +6997,10 @@ function DatabaseSettingsPanelSheet({
5529
6997
  onRefreshSource: (sourceId?: string) => void;
5530
6998
  onHydrateBuilderBodies: (sourceId: string) => void;
5531
6999
  onDisconnectSource: (sourceId?: string) => void;
5532
- onReviewBuilderUpdate: () => void;
5533
- onSetBuilderLiveWrites: (enabled: boolean) => void;
7000
+ onReviewBuilderUpdate: (sourceId: string) => void;
7001
+ onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void;
5534
7002
  sourceActionPending: boolean;
7003
+ sourcePendingOperations: DatabaseSourcePendingOperations;
5535
7004
  onViewTypeChange: (type: ContentDatabaseViewType) => void;
5536
7005
  onWrapCellsChange: (wrapCells: boolean) => void;
5537
7006
  onOpenPagesInChange: (openPagesIn: ContentDatabaseOpenPagesIn) => void;
@@ -5615,6 +7084,7 @@ function DatabaseSettingsPanelSheet({
5615
7084
  documentId={documentId}
5616
7085
  itemCount={items.length}
5617
7086
  canEdit={canEdit}
7087
+ canManage={canManage}
5618
7088
  nav={sourceNavStack}
5619
7089
  onNavPush={(step) => setSourceNavStack((stack) => [...stack, step])}
5620
7090
  onNavReplace={setSourceNavStack}
@@ -5631,6 +7101,7 @@ function DatabaseSettingsPanelSheet({
5631
7101
  onReviewBuilderUpdate={onReviewBuilderUpdate}
5632
7102
  onSetBuilderLiveWrites={onSetBuilderLiveWrites}
5633
7103
  sourceActionPending={sourceActionPending}
7104
+ sourcePendingOperations={sourcePendingOperations}
5634
7105
  />
5635
7106
  ) : panel === "layout" ? (
5636
7107
  <DatabaseSettingsLayoutPanel
@@ -5745,6 +7216,9 @@ export function builderReviewableChangeSets(
5745
7216
  return source.changeSets.filter(
5746
7217
  (changeSet) =>
5747
7218
  changeSet.direction === "outbound" &&
7219
+ !changeSet.executions.some(
7220
+ (execution) => execution.state === "succeeded",
7221
+ ) &&
5748
7222
  (changeSet.state === "pending_push" ||
5749
7223
  changeSet.state === "staged_revision" ||
5750
7224
  changeSet.state === "approved"),
@@ -5770,12 +7244,33 @@ function maxSourceReviewRisk(
5770
7244
 
5771
7245
  export function builderReviewExecutableRows(
5772
7246
  review: ContentDatabaseSourceReviewPayload,
7247
+ selectedChangeSetIds: ReadonlySet<string> = new Set(
7248
+ review.rows.map((row) => row.changeSetId),
7249
+ ),
5773
7250
  ) {
5774
- if (!review.liveWritesEnabled || review.result.status !== "validated") {
5775
- return [];
5776
- }
7251
+ if (!review.liveWritesEnabled) return [];
5777
7252
  return review.rows.filter(
5778
- (row) => row.execution?.state === "ready" && row.execution.idempotencyKey,
7253
+ (row) =>
7254
+ selectedChangeSetIds.has(row.changeSetId) &&
7255
+ row.execution?.idempotencyKey &&
7256
+ (row.execution.state === "ready" ||
7257
+ row.execution.state === "running" ||
7258
+ row.execution.state === "response_received" ||
7259
+ row.execution.state === "reconciliation_required" ||
7260
+ (row.execution.state === "failed" &&
7261
+ builderExecutionPayloadHasSuccessfulResponse(row.execution.payload))),
7262
+ );
7263
+ }
7264
+
7265
+ function builderExecutionPayloadHasSuccessfulResponse(
7266
+ payload: Record<string, unknown>,
7267
+ ) {
7268
+ const response = payload.response;
7269
+ return (
7270
+ !!response &&
7271
+ typeof response === "object" &&
7272
+ !Array.isArray(response) &&
7273
+ (response as Record<string, unknown>).ok === true
5779
7274
  );
5780
7275
  }
5781
7276
 
@@ -5783,24 +7278,67 @@ export function builderSourceLiveWriteControlState(
5783
7278
  source: ContentDatabaseSource | null,
5784
7279
  ) {
5785
7280
  const isBuilderSource = source?.sourceType === "builder-cms";
5786
- const safeTarget =
5787
- isBuilderSource && source?.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL;
5788
- const enabled = source?.capabilities.liveWritesEnabled === true;
7281
+ const legacyAllowedWriteModes = source?.metadata.allowedWriteModes ?? [];
7282
+ const writeMode =
7283
+ source?.metadata.writeMode ??
7284
+ (source?.capabilities.liveWritesEnabled === true
7285
+ ? legacyAllowedWriteModes.some((mode) => mode !== "autosave")
7286
+ ? "publish_updates"
7287
+ : "stage_only"
7288
+ : "read_only");
7289
+ const enabled = writeMode !== "read_only";
5789
7290
  return {
5790
- safeTarget,
7291
+ safeTarget: isBuilderSource,
5791
7292
  enabled,
5792
- showAction: safeTarget,
7293
+ writeMode,
7294
+ allowPublicationTransitions:
7295
+ writeMode === "publish_updates" &&
7296
+ source?.metadata.allowPublicationTransitions === true,
7297
+ availableWriteModes: BUILDER_WRITE_MODE_OPTIONS.map(
7298
+ (option) => option.mode,
7299
+ ),
7300
+ showPublicationTransitions: writeMode === "publish_updates",
7301
+ showAction: isBuilderSource,
5793
7302
  actionLabel: enabled ? "Disable" : "Enable",
5794
7303
  description: enabled
5795
- ? "Enabled for autosave writes to the Agent Native test collection."
5796
- : safeTarget
5797
- ? "Off by default. Enable only when you are ready to send autosave writes to the Agent Native test collection."
5798
- : isBuilderSource
5799
- ? "Unavailable here; live writes are locked to the Agent Native test collection."
5800
- : "Live writes are not available for this source.",
7304
+ ? writeMode === "publish_updates"
7305
+ ? "Approved updates can write through to Builder while preserving publication state."
7306
+ : "Approved updates can create Builder autosave revisions."
7307
+ : isBuilderSource
7308
+ ? "Off by default. An administrator can choose a guarded write tier for this source."
7309
+ : "Live writes are not available for this source.",
5801
7310
  };
5802
7311
  }
5803
7312
 
7313
+ const BUILDER_WRITE_MODE_OPTIONS: Array<{
7314
+ mode: ContentDatabaseSourceWriteMode;
7315
+ labelKey: DatabaseMessageKey;
7316
+ descriptionKey: DatabaseMessageKey;
7317
+ }> = [
7318
+ {
7319
+ mode: "read_only",
7320
+ labelKey: "readOnly",
7321
+ descriptionKey: "noBuilderWrites",
7322
+ },
7323
+ {
7324
+ mode: "stage_only",
7325
+ labelKey: "stageOnly",
7326
+ descriptionKey: "savesDraftsNeverPublishes",
7327
+ },
7328
+ {
7329
+ mode: "publish_updates",
7330
+ labelKey: "publishUpdates",
7331
+ descriptionKey: "writesUpdatesToLiveEntries",
7332
+ },
7333
+ ];
7334
+
7335
+ function builderWriteModeSummary(mode: ContentDatabaseSourceWriteMode) {
7336
+ return dbText(
7337
+ BUILDER_WRITE_MODE_OPTIONS.find((option) => option.mode === mode)
7338
+ ?.descriptionKey ?? "noBuilderWrites",
7339
+ );
7340
+ }
7341
+
5804
7342
  export function buildClientBuilderReviewPayload(
5805
7343
  source: ContentDatabaseSource,
5806
7344
  changeSets: ContentDatabaseSourceChangeSet[],
@@ -5826,6 +7364,7 @@ export function buildClientBuilderReviewPayload(
5826
7364
  (field) => field.localFieldKey === "title",
5827
7365
  );
5828
7366
  const proposedTitle = titleChange?.proposedValue;
7367
+ const effect = resolveBuilderCmsWriteEffect({ source, changeSet });
5829
7368
 
5830
7369
  return {
5831
7370
  changeSetId: changeSet.id,
@@ -5835,12 +7374,14 @@ export function buildClientBuilderReviewPayload(
5835
7374
  typeof proposedTitle === "string" && proposedTitle.trim()
5836
7375
  ? proposedTitle
5837
7376
  : sourceRow?.sourceDisplayKey || "Untitled",
7377
+ targetEntryId:
7378
+ effect === "create_draft" ? null : (sourceRow?.sourceRowId ?? null),
5838
7379
  fieldChanges: changeSet.fieldChanges,
5839
7380
  bodyChange: changeSet.bodyChange,
5840
7381
  riskLevel: changeSet.riskLevel,
5841
7382
  riskReasons: changeSet.riskReasons,
5842
7383
  conflictState: changeSet.conflictState,
5843
- effect: resolveBuilderCmsWriteEffect({ source, changeSet }),
7384
+ effect,
5844
7385
  execution: latestExecution,
5845
7386
  };
5846
7387
  });
@@ -5863,19 +7404,22 @@ export function buildClientBuilderReviewPayload(
5863
7404
  executionStates.length > 0 &&
5864
7405
  executionStates.every((state) => state === "succeeded")
5865
7406
  ? "succeeded"
5866
- : executionStates.includes("failed")
5867
- ? "failed"
5868
- : executionStates.includes("running")
5869
- ? "running"
5870
- : statuses.some((status) => status.status === "stale")
5871
- ? "stale"
5872
- : statuses.some((status) => status.status === "blocked")
5873
- ? "blocked"
5874
- : statuses.some((status) => status.status === "validated")
5875
- ? "validated"
5876
- : source.capabilities.liveWritesEnabled
7407
+ : executionStates.includes("reconciliation_required") ||
7408
+ executionStates.includes("response_received")
7409
+ ? "reconciliation_required"
7410
+ : executionStates.includes("failed")
7411
+ ? "failed"
7412
+ : executionStates.includes("running")
7413
+ ? "running"
7414
+ : statuses.some((status) => status.status === "stale")
7415
+ ? "stale"
7416
+ : statuses.some((status) => status.status === "blocked")
7417
+ ? "blocked"
7418
+ : statuses.some((status) => status.status === "validated")
5877
7419
  ? "validated"
5878
- : "write_disabled";
7420
+ : source.capabilities.liveWritesEnabled
7421
+ ? "validated"
7422
+ : "write_disabled";
5879
7423
 
5880
7424
  return {
5881
7425
  summary:
@@ -5897,19 +7441,21 @@ export function buildClientBuilderReviewPayload(
5897
7441
  ? "Pushed to Builder and reconciled locally."
5898
7442
  : resultStatus === "failed"
5899
7443
  ? "Builder push failed. The change remains retryable."
5900
- : resultStatus === "running"
5901
- ? "Builder push is running."
5902
- : resultStatus === "validated"
5903
- ? source.capabilities.liveWritesEnabled
5904
- ? hasExecutionEvidence
5905
- ? "Push checked successfully. Ready to send to Builder."
5906
- : "Ready to send to Builder."
5907
- : "Push checked successfully. Nothing was sent to Builder."
5908
- : resultStatus === "blocked"
5909
- ? "Push needs attention before anything can be sent to Builder."
5910
- : resultStatus === "stale"
5911
- ? "Push needs a fresh review because the plan changed."
5912
- : "Builder writes are off in this local build. Push will check the update only.",
7444
+ : resultStatus === "reconciliation_required"
7445
+ ? "Builder reconciliation is required. Do not retry this write."
7446
+ : resultStatus === "running"
7447
+ ? "Builder push is running."
7448
+ : resultStatus === "validated"
7449
+ ? source.capabilities.liveWritesEnabled
7450
+ ? hasExecutionEvidence
7451
+ ? "Push checked successfully. Ready to send to Builder."
7452
+ : "Ready to send to Builder."
7453
+ : "Push checked successfully. Nothing was sent to Builder."
7454
+ : resultStatus === "blocked"
7455
+ ? "Push needs attention before anything can be sent to Builder."
7456
+ : resultStatus === "stale"
7457
+ ? "Push needs a fresh review because the plan changed."
7458
+ : "Builder writes are off in this local build. Push will check the update only.",
5913
7459
  },
5914
7460
  };
5915
7461
  }
@@ -5920,6 +7466,7 @@ function DatabaseSettingsSourcePanel({
5920
7466
  documentId,
5921
7467
  itemCount,
5922
7468
  canEdit,
7469
+ canManage,
5923
7470
  nav,
5924
7471
  onNavPush,
5925
7472
  onNavReplace,
@@ -5933,12 +7480,14 @@ function DatabaseSettingsSourcePanel({
5933
7480
  onReviewBuilderUpdate,
5934
7481
  onSetBuilderLiveWrites,
5935
7482
  sourceActionPending,
7483
+ sourcePendingOperations,
5936
7484
  }: {
5937
7485
  source: ContentDatabaseSource | null;
5938
7486
  sources: ContentDatabaseSource[];
5939
7487
  documentId: string;
5940
7488
  itemCount: number;
5941
7489
  canEdit: boolean;
7490
+ canManage: boolean;
5942
7491
  nav: SourceNavStep[];
5943
7492
  onNavPush: (step: SourceNavStep) => void;
5944
7493
  onNavReplace: (stack: SourceNavStep[]) => void;
@@ -5959,26 +7508,13 @@ function DatabaseSettingsSourcePanel({
5959
7508
  onRefreshSource: (sourceId?: string) => void;
5960
7509
  onHydrateBuilderBodies: (sourceId: string) => void;
5961
7510
  onDisconnectSource: (sourceId?: string) => void;
5962
- onReviewBuilderUpdate: () => void;
5963
- onSetBuilderLiveWrites: (enabled: boolean) => void;
7511
+ onReviewBuilderUpdate: (sourceId: string) => void;
7512
+ onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void;
5964
7513
  sourceActionPending: boolean;
7514
+ sourcePendingOperations: DatabaseSourcePendingOperations;
5965
7515
  }) {
5966
- const outboundChangeSets =
5967
- source?.changeSets.filter(
5968
- (changeSet) => changeSet.direction === "outbound",
5969
- ) ?? [];
5970
- const reviewableBuilderChangeSets = outboundChangeSets.filter(
5971
- (changeSet) =>
5972
- changeSet.state === "pending_push" ||
5973
- changeSet.state === "staged_revision" ||
5974
- changeSet.state === "approved",
5975
- );
5976
- const conflictChangeSets =
5977
- source?.changeSets.filter(
5978
- (changeSet) => changeSet.conflictState === "source_changed",
5979
- ) ?? [];
5980
7516
  const { isCodeMode } = useCodeMode();
5981
- const isBuilderSource = source?.sourceType === "builder-cms";
7517
+ const builderSources = databaseAttachedBuilderSources(sources, source);
5982
7518
  const builderStatus = useBuilderStatus();
5983
7519
  const builderConfigured = builderStatus.status?.configured === true;
5984
7520
  const builderOrgName = builderStatus.status?.orgName ?? null;
@@ -5997,10 +7533,6 @@ function DatabaseSettingsSourcePanel({
5997
7533
  void builderStatus.refetch();
5998
7534
  },
5999
7535
  });
6000
- const builderSyncFailed =
6001
- isBuilderSource &&
6002
- (source?.syncState === "error" || Boolean(source?.lastError));
6003
-
6004
7536
  const top = nav[nav.length - 1];
6005
7537
 
6006
7538
  // ── Sources list (root) ───────────────────────────────────────────────
@@ -6011,9 +7543,21 @@ function DatabaseSettingsSourcePanel({
6011
7543
  sources={sources}
6012
7544
  builderConfigured={builderConfigured}
6013
7545
  builderSpaceLabel={builderSpaceLabel}
6014
- reviewableCount={reviewableBuilderChangeSets.length}
6015
- onOpenBuilder={() =>
6016
- onNavPush({ kind: "provider", providerId: "builder" })
7546
+ reviewableCount={builderSources.reduce(
7547
+ (total, candidate) =>
7548
+ total + builderReviewableChangeSets(candidate).length,
7549
+ 0,
7550
+ )}
7551
+ onOpenBuilder={(builderSource) =>
7552
+ onNavPush(
7553
+ builderSource
7554
+ ? {
7555
+ kind: "model",
7556
+ sourceId: builderSource.id,
7557
+ sourceName: builderSource.sourceName,
7558
+ }
7559
+ : { kind: "provider", providerId: "builder" },
7560
+ )
6017
7561
  }
6018
7562
  onOpenNotion={() => onNavPush({ kind: "addSource" })}
6019
7563
  onOpenSecondary={(secondary) =>
@@ -6217,22 +7761,28 @@ function DatabaseSettingsSourcePanel({
6217
7761
  if (top.kind === "space") {
6218
7762
  return (
6219
7763
  <BuilderSpaceModelsView
6220
- attachedModelName={
6221
- isBuilderSource ? (source?.sourceTable ?? null) : null
6222
- }
6223
- onOpenModel={(model) => onNavPush({ kind: "model", model })}
7764
+ attachedModelNames={databaseAttachedBuilderModelNames(sources, source)}
7765
+ onOpenModel={(model) => {
7766
+ const attached = databaseAttachedBuilderSource(sources, source, {
7767
+ modelName: model.name,
7768
+ });
7769
+ onNavPush({ kind: "model", model, sourceId: attached?.id });
7770
+ }}
6224
7771
  />
6225
7772
  );
6226
7773
  }
6227
7774
 
6228
7775
  // ── Model leaf ────────────────────────────────────────────────────────
6229
7776
  const model = top.model;
6230
- const isAttachedModel =
6231
- Boolean(source) && isBuilderSource && source?.sourceTable === model.name;
7777
+ const selectedBuilderSource = databaseAttachedBuilderSource(sources, source, {
7778
+ sourceId: top.sourceId,
7779
+ modelName: model?.name,
7780
+ });
6232
7781
 
6233
7782
  // Unattached model → the attach affordance (the model is already chosen by
6234
7783
  // drilling in, so there's no model picker here).
6235
- if (!isAttachedModel || !source) {
7784
+ if (!selectedBuilderSource) {
7785
+ if (!model) return null;
6236
7786
  return (
6237
7787
  <div className="grid min-w-0 gap-3">
6238
7788
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
@@ -6311,41 +7861,91 @@ function DatabaseSettingsSourcePanel({
6311
7861
  );
6312
7862
  }
6313
7863
 
6314
- // Attached model → the minimal read-only leaf panel.
7864
+ const selectedSource = selectedBuilderSource;
7865
+ const reviewableBuilderChangeSets =
7866
+ builderReviewableChangeSets(selectedSource);
7867
+ const outboundChangeSets = reviewableBuilderChangeSets;
7868
+ const conflictChangeSets = selectedSource.changeSets.filter(
7869
+ (changeSet) => changeSet.conflictState === "source_changed",
7870
+ );
7871
+ const builderSyncFailed =
7872
+ selectedSource.syncState === "error" || Boolean(selectedSource.lastError);
7873
+ const liveWriteControl = builderSourceLiveWriteControlState(selectedSource);
7874
+ const builderWriteMode = liveWriteControl.writeMode;
7875
+ const selectedSourceMutationPending = databaseSourceOperationIsPending(
7876
+ sourcePendingOperations,
7877
+ selectedSource.id,
7878
+ [
7879
+ "changeRole",
7880
+ "refresh",
7881
+ "hydration",
7882
+ "disconnect",
7883
+ "review",
7884
+ "execute",
7885
+ "writeMode",
7886
+ ],
7887
+ );
7888
+ const selectedSourceReviewPending = databaseSourceOperationIsPending(
7889
+ sourcePendingOperations,
7890
+ selectedSource.id,
7891
+ ["refresh", "disconnect", "review", "execute", "writeMode"],
7892
+ );
7893
+ const selectedSourceHydrationPending = databaseSourceOperationIsPending(
7894
+ sourcePendingOperations,
7895
+ selectedSource.id,
7896
+ ["refresh", "hydration", "disconnect"],
7897
+ );
7898
+ const selectedSourceRefreshControlPending =
7899
+ sourcePendingOperations.refresh || selectedSourceMutationPending;
7900
+ const selectedSourceReviewControlPending =
7901
+ sourcePendingOperations.review ||
7902
+ sourcePendingOperations.execute ||
7903
+ selectedSourceReviewPending;
7904
+ const selectedSourceHydrationControlPending =
7905
+ sourcePendingOperations.hydration || selectedSourceHydrationPending;
7906
+ const selectedSourceRoleControlPending =
7907
+ sourcePendingOperations.changeRole || selectedSourceMutationPending;
7908
+ const selectedSourceDisconnectControlPending =
7909
+ sourcePendingOperations.disconnect || selectedSourceMutationPending;
7910
+ const builderWriteModeDisabled =
7911
+ !canManage ||
7912
+ databaseBuilderWriteModeOperationPending(
7913
+ sourcePendingOperations,
7914
+ selectedSource.id,
7915
+ );
7916
+
7917
+ // Attached model → source details and guarded write policy.
6315
7918
  return (
6316
7919
  <div className="grid min-w-0 gap-4">
6317
7920
  <>
6318
7921
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
6319
7922
  <div className="flex min-w-0 items-center justify-between gap-2">
6320
- <span className="truncate font-medium" title={source.sourceName}>
6321
- {source.sourceName}
7923
+ <span
7924
+ className="truncate font-medium"
7925
+ title={selectedSource.sourceName}
7926
+ >
7927
+ {selectedSource.sourceName}
6322
7928
  </span>
6323
7929
  <div className="flex shrink-0 items-center gap-1">
6324
- {isBuilderSource ? (
6325
- source.capabilities.liveWritesEnabled ? (
6326
- <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-foreground">
6327
- <IconPencil className="size-3" />
6328
- {dbText("liveWritesOn")}
6329
- </span>
6330
- ) : (
6331
- <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
6332
- <IconLock className="size-3" />
6333
- {dbText("readOnly")}
6334
- </span>
6335
- )
7930
+ {selectedSource.capabilities.liveWritesEnabled ? (
7931
+ <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-foreground">
7932
+ <IconPencil className="size-3" />
7933
+ {dbText("liveWritesOn")}
7934
+ </span>
6336
7935
  ) : (
6337
- <span className="rounded-full border border-border px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
6338
- {source.syncState}
7936
+ <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
7937
+ <IconLock className="size-3" />
7938
+ {dbText("readOnly")}
6339
7939
  </span>
6340
7940
  )}
6341
- {isBuilderSource && !builderSyncFailed ? (
7941
+ {!builderSyncFailed ? (
6342
7942
  <Button
6343
7943
  type="button"
6344
7944
  variant="ghost"
6345
7945
  size="sm"
6346
7946
  className="h-7 gap-1 px-2 text-xs"
6347
- disabled={!canEdit || sourceActionPending}
6348
- onClick={() => onRefreshSource(source.id)}
7947
+ disabled={!canEdit || selectedSourceRefreshControlPending}
7948
+ onClick={() => onRefreshSource(selectedSource.id)}
6349
7949
  >
6350
7950
  <IconRefresh className="size-3" />
6351
7951
  {dbText("refreshSource")}
@@ -6358,109 +7958,186 @@ function DatabaseSettingsSourcePanel({
6358
7958
  <button
6359
7959
  type="button"
6360
7960
  className="inline-flex items-center gap-1 text-destructive hover:underline disabled:opacity-60"
6361
- disabled={!canEdit || sourceActionPending}
6362
- onClick={() => onRefreshSource(source.id)}
7961
+ disabled={!canEdit || selectedSourceRefreshControlPending}
7962
+ onClick={() => onRefreshSource(selectedSource.id)}
6363
7963
  >
6364
7964
  <IconRefresh className="size-3" />
6365
7965
  {dbText("couldntSyncRetry")}
6366
7966
  </button>
6367
- ) : isBuilderSource ? (
7967
+ ) : (
6368
7968
  [
6369
7969
  builderConfigured ? (builderSpaceLabel ?? "Connected") : null,
6370
- source.lastRefreshedAt
7970
+ selectedSource.lastRefreshedAt
6371
7971
  ? `synced ${
6372
- formatRelativeSyncTime(source.lastRefreshedAt) ??
6373
- source.freshness
7972
+ formatRelativeSyncTime(selectedSource.lastRefreshedAt) ??
7973
+ selectedSource.freshness
6374
7974
  }`
6375
- : source.freshness,
6376
- typeof source.metadata.lastReadFetchedEntryCount === "number"
7975
+ : selectedSource.freshness,
7976
+ typeof selectedSource.metadata.lastReadFetchedEntryCount ===
7977
+ "number"
6377
7978
  ? dbText("builderRowsFetched", {
6378
- count: source.metadata.lastReadFetchedEntryCount,
7979
+ count: selectedSource.metadata.lastReadFetchedEntryCount,
6379
7980
  })
6380
7981
  : null,
6381
- builderSourceRowFetchStatus(source) === "error"
7982
+ builderSourceRowFetchStatus(selectedSource) === "error"
6382
7983
  ? dbText("builderRowsFetchFailed")
6383
- : builderSourceRowFetchStatus(source) === "fetching"
7984
+ : builderSourceRowFetchStatus(selectedSource) === "fetching"
6384
7985
  ? dbText("builderRowsFetchingMore")
6385
7986
  : null,
6386
7987
  ]
6387
7988
  .filter(Boolean)
6388
7989
  .join(" · ")
6389
- ) : (
6390
- `Local snapshot · ${source.freshness}`
6391
7990
  )}
6392
7991
  </div>
7992
+ <div className="grid min-w-0 gap-2 border-t border-border pt-3 text-xs text-muted-foreground">
7993
+ <div className="flex items-center justify-between gap-3">
7994
+ <span className="font-medium text-foreground">
7995
+ {dbText("builderWriteMode")}
7996
+ </span>
7997
+ <span>{builderWriteModeSummary(builderWriteMode)}</span>
7998
+ </div>
7999
+ <div
8000
+ className="grid grid-cols-3 gap-0.5 rounded-md border border-border bg-muted/35 p-0.5"
8001
+ aria-label={dbText("builderWriteMode")}
8002
+ >
8003
+ {BUILDER_WRITE_MODE_OPTIONS.map((option) => {
8004
+ const selected = builderWriteMode === option.mode;
8005
+ return (
8006
+ <button
8007
+ key={option.mode}
8008
+ type="button"
8009
+ aria-pressed={selected}
8010
+ title={dbText(option.descriptionKey)}
8011
+ disabled={builderWriteModeDisabled}
8012
+ className={cn(
8013
+ "min-w-0 rounded px-2 py-1.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
8014
+ selected
8015
+ ? "bg-[#2383e2] text-white shadow-sm"
8016
+ : "text-muted-foreground hover:bg-background hover:text-foreground",
8017
+ )}
8018
+ onClick={() =>
8019
+ onSetBuilderLiveWrites({
8020
+ sourceId: selectedSource.id,
8021
+ writeMode: option.mode,
8022
+ allowPublicationTransitions:
8023
+ option.mode === "publish_updates"
8024
+ ? liveWriteControl.allowPublicationTransitions
8025
+ : false,
8026
+ })
8027
+ }
8028
+ >
8029
+ <span className="block truncate">
8030
+ {dbText(option.labelKey)}
8031
+ </span>
8032
+ </button>
8033
+ );
8034
+ })}
8035
+ </div>
8036
+ {liveWriteControl.showPublicationTransitions ? (
8037
+ <button
8038
+ type="button"
8039
+ role="checkbox"
8040
+ aria-checked={liveWriteControl.allowPublicationTransitions}
8041
+ disabled={builderWriteModeDisabled}
8042
+ className="flex min-w-0 items-start gap-2 rounded px-1 py-1 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60"
8043
+ onClick={() =>
8044
+ onSetBuilderLiveWrites({
8045
+ sourceId: selectedSource.id,
8046
+ writeMode: "publish_updates",
8047
+ allowPublicationTransitions:
8048
+ !liveWriteControl.allowPublicationTransitions,
8049
+ })
8050
+ }
8051
+ >
8052
+ <span
8053
+ className={cn(
8054
+ "mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border",
8055
+ liveWriteControl.allowPublicationTransitions
8056
+ ? "border-[#2383e2] bg-[#2383e2] text-white"
8057
+ : "border-muted-foreground/40 bg-background text-transparent",
8058
+ )}
8059
+ >
8060
+ {liveWriteControl.allowPublicationTransitions ? (
8061
+ <IconCheck className="size-3" />
8062
+ ) : null}
8063
+ </span>
8064
+ <span className="min-w-0">
8065
+ <span className="block text-foreground">
8066
+ {dbText("allowPublishUnpublishPerItem")}
8067
+ </span>
8068
+ </span>
8069
+ </button>
8070
+ ) : null}
8071
+ </div>
6393
8072
  </div>
6394
8073
 
6395
- {reviewableBuilderChangeSets.length > 0 ||
6396
- conflictChangeSets.length > 0 ? (
6397
- <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-muted/30 p-3">
6398
- <div className="flex items-center justify-between gap-3">
6399
- <div className="min-w-0">
6400
- <div className="text-sm font-medium">
6401
- {conflictChangeSets.length > 0
6402
- ? `${conflictChangeSets.length} change${
6403
- conflictChangeSets.length === 1 ? "" : "s"
6404
- } need review`
6405
- : `${reviewableBuilderChangeSets.length} change${
8074
+ <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-muted/30 p-3">
8075
+ <div className="flex items-center justify-between gap-3">
8076
+ <div className="min-w-0">
8077
+ <div className="text-sm font-medium">
8078
+ {conflictChangeSets.length > 0
8079
+ ? `${conflictChangeSets.length} change${
8080
+ conflictChangeSets.length === 1 ? "" : "s"
8081
+ } need review`
8082
+ : reviewableBuilderChangeSets.length > 0
8083
+ ? `${reviewableBuilderChangeSets.length} change${
6406
8084
  reviewableBuilderChangeSets.length === 1 ? "" : "s"
6407
- } ready to push`}
6408
- </div>
6409
- <div className="mt-0.5 break-words text-xs text-muted-foreground">
6410
- {dbText("reviewBeforeTheyReachBuilder")}
6411
- </div>
8085
+ } ready to push`
8086
+ : "Check for local content changes"}
8087
+ </div>
8088
+ <div className="mt-0.5 break-words text-xs text-muted-foreground">
8089
+ {dbText("reviewDiffDescription")}
6412
8090
  </div>
6413
- <Button
6414
- type="button"
6415
- size="sm"
6416
- className="shrink-0"
6417
- disabled={!canEdit || sourceActionPending}
6418
- onClick={onReviewBuilderUpdate}
6419
- >
6420
- <IconCheck className="mr-1.5 size-3.5" />
6421
- {dbText("reviewDiff")}
6422
- </Button>
6423
8091
  </div>
8092
+ <Button
8093
+ type="button"
8094
+ size="sm"
8095
+ className="shrink-0"
8096
+ disabled={!canEdit || selectedSourceReviewControlPending}
8097
+ onClick={() => onReviewBuilderUpdate(selectedSource.id)}
8098
+ >
8099
+ <IconCheck className="mr-1.5 size-3.5" />
8100
+ {dbText("reviewDiff")}
8101
+ </Button>
6424
8102
  </div>
6425
- ) : null}
8103
+ </div>
6426
8104
 
6427
- {isBuilderSource && source.bodyHydration ? (
8105
+ {selectedSource.bodyHydration ? (
6428
8106
  <BuilderBodyHydrationCard
6429
- source={source}
8107
+ source={selectedSource}
6430
8108
  canEdit={canEdit}
6431
- pending={sourceActionPending}
6432
- onHydrate={() => onHydrateBuilderBodies(source.id)}
8109
+ pending={selectedSourceHydrationControlPending}
8110
+ onHydrate={() => onHydrateBuilderBodies(selectedSource.id)}
6433
8111
  />
6434
8112
  ) : null}
6435
8113
 
8114
+ <BuilderRequiredFieldsCard
8115
+ documentId={documentId}
8116
+ source={selectedSource}
8117
+ canEdit={canEdit}
8118
+ pending={selectedSourceMutationPending}
8119
+ />
8120
+
6436
8121
  {isCodeMode ? (
6437
8122
  <>
6438
8123
  <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3 text-sm">
6439
- <div className="font-medium">
6440
- {source.sourceType === "builder-cms"
6441
- ? "Local Builder changes"
6442
- : "Local outbound changes"}
6443
- </div>
8124
+ <div className="font-medium">{dbText("builderChanges")}</div>
6444
8125
  <div className="text-xs text-muted-foreground">
6445
- {source.sourceType === "builder-cms"
6446
- ? source.capabilities.liveWritesEnabled
6447
- ? "Local edits can be reviewed and sent through the guarded Builder autosave path."
6448
- : "Local edits can be staged as a Builder save revision/autosave record. Live Builder writes are disabled."
6449
- : "No local outbound push lane is active for this mock source."}
8126
+ {selectedSource.capabilities.liveWritesEnabled
8127
+ ? "Local edits can be reviewed and sent through the guarded Builder autosave path."
8128
+ : "Local edits can be staged as a Builder save revision/autosave record. Live Builder writes are disabled."}
6450
8129
  </div>
6451
8130
  <div className="grid min-w-0 gap-2">
6452
8131
  {outboundChangeSets.slice(0, 6).map((changeSet) => (
6453
8132
  <SourceChangeSetReviewCard
6454
8133
  key={changeSet.id}
6455
8134
  changeSet={changeSet}
6456
- source={source}
8135
+ source={selectedSource}
6457
8136
  />
6458
8137
  ))}
6459
8138
  {outboundChangeSets.length === 0 ? (
6460
8139
  <div className="text-xs text-muted-foreground">
6461
- {source.sourceType === "builder-cms"
6462
- ? "No pending local Builder changes yet. Rename a source-backed row to see a local outbound diff."
6463
- : "No local outbound changes yet."}
8140
+ {dbText("noPendingChangesEditASourceBackedRow")}
6464
8141
  </div>
6465
8142
  ) : null}
6466
8143
  </div>
@@ -6469,33 +8146,33 @@ function DatabaseSettingsSourcePanel({
6469
8146
  ) : null}
6470
8147
 
6471
8148
  <SourceRoleCard
6472
- source={source}
8149
+ source={selectedSource}
6473
8150
  canAddDetails={sources.some(
6474
- (item) => item.id !== source.id && !sourceAddsDetails(item),
8151
+ (item) => item.id !== selectedSource.id && !sourceAddsDetails(item),
6475
8152
  )}
6476
8153
  canEdit={canEdit}
6477
- pending={sourceActionPending}
8154
+ pending={selectedSourceRoleControlPending}
6478
8155
  onAddDetails={() =>
6479
8156
  onNavPush({
6480
8157
  kind: "keyConfirm",
6481
8158
  candidate: {
6482
- sourceType: source.sourceType,
6483
- sourceName: source.sourceName,
6484
- sourceTable: source.sourceTable,
6485
- displayName: source.sourceName,
6486
- existingSourceId: source.id,
8159
+ sourceType: selectedSource.sourceType,
8160
+ sourceName: selectedSource.sourceName,
8161
+ sourceTable: selectedSource.sourceTable,
8162
+ displayName: selectedSource.sourceName,
8163
+ existingSourceId: selectedSource.id,
6487
8164
  },
6488
8165
  })
6489
8166
  }
6490
8167
  onAddItems={async () => {
6491
- await onChangeSourceRole(source.id, "items");
8168
+ await onChangeSourceRole(selectedSource.id, "items");
6492
8169
  onNavReplace([]);
6493
8170
  }}
6494
8171
  onChooseFields={() =>
6495
8172
  onNavPush({
6496
8173
  kind: "fieldPicker",
6497
- sourceId: source.id,
6498
- sourceName: source.sourceName,
8174
+ sourceId: selectedSource.id,
8175
+ sourceName: selectedSource.sourceName,
6499
8176
  })
6500
8177
  }
6501
8178
  />
@@ -6512,10 +8189,10 @@ function DatabaseSettingsSourcePanel({
6512
8189
  size="sm"
6513
8190
  variant="outline"
6514
8191
  className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
6515
- disabled={!canEdit || sourceActionPending}
6516
- onClick={() => onDisconnectSource(source.id)}
8192
+ disabled={!canEdit || selectedSourceDisconnectControlPending}
8193
+ onClick={() => onDisconnectSource(selectedSource.id)}
6517
8194
  >
6518
- {sourceActionPending ? (
8195
+ {selectedSourceDisconnectControlPending ? (
6519
8196
  <Spinner className="mr-1 size-3.5" />
6520
8197
  ) : (
6521
8198
  <IconX className="mr-1 size-3.5" />
@@ -6546,7 +8223,7 @@ function SourcesListView({
6546
8223
  builderConfigured: boolean;
6547
8224
  builderSpaceLabel: string | null;
6548
8225
  reviewableCount: number;
6549
- onOpenBuilder: () => void;
8226
+ onOpenBuilder: (source?: ContentDatabaseSource) => void;
6550
8227
  onOpenNotion: () => void;
6551
8228
  onOpenSecondary: (source: ContentDatabaseSource) => void;
6552
8229
  onAddSource: () => void;
@@ -6581,7 +8258,7 @@ function SourcesListView({
6581
8258
  connected.metadata.federation?.role === "secondary"
6582
8259
  ? () => onOpenSecondary(connected)
6583
8260
  : connected.sourceType === "builder-cms"
6584
- ? onOpenBuilder
8261
+ ? () => onOpenBuilder(connected)
6585
8262
  : undefined
6586
8263
  }
6587
8264
  disabled={
@@ -7211,12 +8888,7 @@ function BuilderSourceContinuationBar({
7211
8888
  canEdit: boolean;
7212
8889
  pending: boolean;
7213
8890
  clientError: boolean;
7214
- progressHighWater: {
7215
- sourceId: string | null;
7216
- fetchedCount: number;
7217
- hydratedCount: number;
7218
- rowsComplete: boolean;
7219
- };
8891
+ progressHighWater?: BuilderSourceProgressHighWater;
7220
8892
  onRetry: () => void;
7221
8893
  }) {
7222
8894
  if (
@@ -7228,8 +8900,7 @@ function BuilderSourceContinuationBar({
7228
8900
  }
7229
8901
  const rowsComplete =
7230
8902
  source.metadata.lastReadHasMore === false ||
7231
- (progressHighWater.sourceId === source.id &&
7232
- progressHighWater.rowsComplete);
8903
+ progressHighWater?.rowsComplete === true;
7233
8904
  const rawStatus = clientError ? "error" : builderSourceRowFetchStatus(source);
7234
8905
  const status = rowsComplete && rawStatus === "fetching" ? null : rawStatus;
7235
8906
  const bodyHydration = source.bodyHydration;
@@ -7242,10 +8913,7 @@ function BuilderSourceContinuationBar({
7242
8913
  const hydratedCount = bodyHydration
7243
8914
  ? builderBodyHydrationDisplayHydratedCount({
7244
8915
  summary: bodyHydration,
7245
- highWaterCount:
7246
- progressHighWater.sourceId === source.id
7247
- ? progressHighWater.hydratedCount
7248
- : 0,
8916
+ highWaterCount: progressHighWater?.hydratedCount ?? 0,
7249
8917
  })
7250
8918
  : 0;
7251
8919
  const fetchedCount =
@@ -7253,9 +8921,7 @@ function BuilderSourceContinuationBar({
7253
8921
  ? Math.max(
7254
8922
  rawFetchedCount ?? 0,
7255
8923
  bodyHydration.total,
7256
- progressHighWater.sourceId === source.id
7257
- ? progressHighWater.fetchedCount
7258
- : 0,
8924
+ progressHighWater?.fetchedCount ?? 0,
7259
8925
  )
7260
8926
  : rawFetchedCount;
7261
8927
  const hasMore = source.metadata.lastReadHasMore === true && !rowsComplete;
@@ -7502,6 +9168,105 @@ function sourceFieldIconType(
7502
9168
  return "text";
7503
9169
  }
7504
9170
 
9171
+ export function builderMissingRequiredFields(
9172
+ source: ContentDatabaseSource | null | undefined,
9173
+ ) {
9174
+ if (
9175
+ source?.sourceType !== "builder-cms" ||
9176
+ source.sourceTable !== "agent-native-blog-article-test"
9177
+ ) {
9178
+ return [];
9179
+ }
9180
+
9181
+ const requiredFieldKeys = new Set(
9182
+ (source.metadata.builderModelFields ?? [])
9183
+ .filter(
9184
+ (field) =>
9185
+ field.required &&
9186
+ field.name !== "title" &&
9187
+ field.name !== "blocks" &&
9188
+ field.name !== "blocksString",
9189
+ )
9190
+ .map((field) => `data.${field.name}`),
9191
+ );
9192
+
9193
+ return source.fields.filter(
9194
+ (field) =>
9195
+ !field.propertyId &&
9196
+ field.mappingType !== "system" &&
9197
+ field.mappingType !== "title" &&
9198
+ requiredFieldKeys.has(field.sourceFieldKey),
9199
+ );
9200
+ }
9201
+
9202
+ export function BuilderRequiredFieldsCard({
9203
+ documentId,
9204
+ source,
9205
+ canEdit,
9206
+ pending,
9207
+ onDone,
9208
+ }: {
9209
+ documentId: string;
9210
+ source: ContentDatabaseSource;
9211
+ canEdit: boolean;
9212
+ pending: boolean;
9213
+ onDone?: () => void;
9214
+ }) {
9215
+ const addRequiredFields = useMaterializeBuilderRequiredFields(documentId);
9216
+ const missingRequiredFields = builderMissingRequiredFields(source);
9217
+
9218
+ if (missingRequiredFields.length === 0) return null;
9219
+
9220
+ const addRequired = async () => {
9221
+ try {
9222
+ await addRequiredFields.mutateAsync({
9223
+ documentId,
9224
+ sourceId: source.id,
9225
+ });
9226
+ toast.success(dbText("publishingFieldsAdded"), {
9227
+ description: dbText("requiredBuilderFieldsReady", {
9228
+ count: missingRequiredFields.length,
9229
+ }),
9230
+ });
9231
+ onDone?.();
9232
+ } catch (error) {
9233
+ toast.error(dbText("fieldsWereNotAdded"), {
9234
+ description:
9235
+ error instanceof Error ? error.message : dbText("tryAgain"),
9236
+ });
9237
+ }
9238
+ };
9239
+
9240
+ return (
9241
+ <div className="grid gap-2 rounded-lg border border-border bg-muted/30 p-3">
9242
+ <div className="text-sm font-medium">
9243
+ {dbText("requiredPublishingFields")}
9244
+ </div>
9245
+ <div className="text-xs text-muted-foreground">
9246
+ {dbText("requiredPublishingFieldsDescription", {
9247
+ fields: missingRequiredFields
9248
+ .map((field) => field.sourceFieldLabel)
9249
+ .join(", "),
9250
+ })}
9251
+ </div>
9252
+ <Button
9253
+ type="button"
9254
+ size="sm"
9255
+ className="justify-self-start"
9256
+ disabled={!canEdit || pending || addRequiredFields.isPending}
9257
+ onClick={addRequired}
9258
+ >
9259
+ {addRequiredFields.isPending ? (
9260
+ <Spinner className="mr-1.5 size-3.5" />
9261
+ ) : (
9262
+ <IconPlus className="mr-1.5 size-3.5" />
9263
+ )}
9264
+ {dbText("addRequiredFields")}
9265
+ </Button>
9266
+ </div>
9267
+ );
9268
+ }
9269
+
7505
9270
  function SourceDetailsFieldPicker({
7506
9271
  documentId,
7507
9272
  source,
@@ -7586,7 +9351,6 @@ function SourceDetailsFieldPicker({
7586
9351
  });
7587
9352
  }
7588
9353
  };
7589
-
7590
9354
  return (
7591
9355
  <div className="grid min-w-0 gap-3">
7592
9356
  <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3">
@@ -7597,6 +9361,13 @@ function SourceDetailsFieldPicker({
7597
9361
  {dbText("pickDetailsBecomeColumns")}
7598
9362
  </div>
7599
9363
  </div>
9364
+ <BuilderRequiredFieldsCard
9365
+ documentId={documentId}
9366
+ source={source}
9367
+ canEdit={canEdit}
9368
+ pending={pending}
9369
+ onDone={onDone}
9370
+ />
7600
9371
  {fields.length === 0 ? (
7601
9372
  <div className="break-words text-xs text-muted-foreground">
7602
9373
  {dbText("allAvailableDetailFieldsAlreadyVisible")}
@@ -7667,10 +9438,10 @@ function SourceDetailsFieldPicker({
7667
9438
  // A Builder space's data models, as drill-in rows. The attached model (if any)
7668
9439
  // is marked; selecting a row opens that model's leaf.
7669
9440
  function BuilderSpaceModelsView({
7670
- attachedModelName,
9441
+ attachedModelNames,
7671
9442
  onOpenModel,
7672
9443
  }: {
7673
- attachedModelName: string | null;
9444
+ attachedModelNames: string[];
7674
9445
  onOpenModel: (model: BuilderCmsModelSummary) => void;
7675
9446
  }) {
7676
9447
  const modelsQuery = useBuilderCmsModels(true);
@@ -7678,22 +9449,27 @@ function BuilderSpaceModelsView({
7678
9449
  const [query, setQuery] = useState("");
7679
9450
 
7680
9451
  if (modelsQuery.isLoading) {
7681
- if (attachedModelName) {
9452
+ if (attachedModelNames.length > 0) {
7682
9453
  return (
7683
9454
  <div className="grid min-w-0 gap-2">
7684
9455
  <div className="grid min-w-0 gap-1.5">
7685
9456
  <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
7686
9457
  {dbText("alreadyAttached")}
7687
9458
  </div>
7688
- <div className="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm">
7689
- <span className="flex min-w-0 items-center gap-2">
7690
- <IconLayoutGrid className="size-4 shrink-0 text-muted-foreground" />
7691
- <span className="min-w-0 truncate">{attachedModelName}</span>
7692
- </span>
7693
- <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
7694
- attached
7695
- </span>
7696
- </div>
9459
+ {attachedModelNames.map((attachedModelName) => (
9460
+ <div
9461
+ key={attachedModelName}
9462
+ className="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm"
9463
+ >
9464
+ <span className="flex min-w-0 items-center gap-2">
9465
+ <IconLayoutGrid className="size-4 shrink-0 text-muted-foreground" />
9466
+ <span className="min-w-0 truncate">{attachedModelName}</span>
9467
+ </span>
9468
+ <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
9469
+ attached
9470
+ </span>
9471
+ </div>
9472
+ ))}
7697
9473
  </div>
7698
9474
  <div className="flex min-w-0 items-center gap-2 px-2 text-xs text-muted-foreground">
7699
9475
  <Spinner className="size-3.5" />
@@ -7767,15 +9543,16 @@ function BuilderSpaceModelsView({
7767
9543
  model.displayName.toLowerCase().includes(normalizedQuery) ||
7768
9544
  model.name.toLowerCase().includes(normalizedQuery);
7769
9545
  const filtered = models.filter(matchesQuery);
7770
- const attachedModels = filtered.filter(
7771
- (model) => attachedModelName === model.name,
9546
+ const attachedModelNameSet = new Set(attachedModelNames);
9547
+ const attachedModels = filtered.filter((model) =>
9548
+ attachedModelNameSet.has(model.name),
7772
9549
  );
7773
9550
  const otherModels = filtered.filter(
7774
- (model) => attachedModelName !== model.name,
9551
+ (model) => !attachedModelNameSet.has(model.name),
7775
9552
  );
7776
9553
 
7777
9554
  const renderRow = (model: BuilderCmsModelSummary) => {
7778
- const isAttached = attachedModelName === model.name;
9555
+ const isAttached = attachedModelNameSet.has(model.name);
7779
9556
  return (
7780
9557
  <button
7781
9558
  key={model.id}