@agent-native/core 0.101.6 → 0.101.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) 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/artifact-response.ts +255 -12
  16. package/corpus/core/src/agent/thread-data-builder.ts +84 -11
  17. package/corpus/core/src/client/review/ReviewCommentComposer.tsx +9 -1
  18. package/corpus/core/src/client/review/ReviewThreadPanel.tsx +17 -2
  19. package/corpus/core/src/client/settings/SettingsPanel.tsx +41 -21
  20. package/corpus/core/src/client/settings/useBuilderStatus.ts +28 -9
  21. package/corpus/core/src/client/setup-connections/BuilderConnectCard.tsx +2 -7
  22. package/corpus/core/src/integrations/a2a-continuation-processor.ts +99 -7
  23. package/corpus/core/src/integrations/adapters/discord.ts +3 -1
  24. package/corpus/core/src/integrations/adapters/email.ts +4 -1
  25. package/corpus/core/src/integrations/adapters/google-docs.ts +4 -1
  26. package/corpus/core/src/integrations/adapters/microsoft-teams.ts +3 -1
  27. package/corpus/core/src/integrations/adapters/slack.ts +38 -9
  28. package/corpus/core/src/integrations/adapters/telegram.ts +32 -10
  29. package/corpus/core/src/integrations/adapters/whatsapp.ts +5 -1
  30. package/corpus/core/src/integrations/index.ts +1 -0
  31. package/corpus/core/src/integrations/types.ts +9 -2
  32. package/corpus/core/src/integrations/webhook-handler.ts +69 -45
  33. package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +1 -0
  34. package/corpus/core/src/server/auth.ts +25 -1
  35. package/corpus/core/src/server/builder-browser.ts +353 -1
  36. package/corpus/core/src/server/core-routes-plugin.ts +601 -223
  37. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +80 -1
  38. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +28 -3
  39. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +5 -1
  40. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +30 -3
  41. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +196 -0
  42. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +115 -1
  43. package/corpus/templates/calendar/app/lib/out-of-office.ts +45 -0
  44. package/corpus/templates/calendar/changelog/2026-07-14-out-of-office-blocks-now-sit-behind-meetings-and-event-stack.md +6 -0
  45. package/corpus/templates/content/.agents/skills/content/SKILL.md +31 -0
  46. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +35 -21
  47. package/corpus/templates/content/AGENTS.md +32 -9
  48. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +87 -0
  49. package/corpus/templates/content/actions/_builder-cms-read-client.ts +173 -25
  50. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  51. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +427 -18
  52. package/corpus/templates/content/actions/_builder-cms-write-client.ts +17 -73
  53. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +27 -7
  54. package/corpus/templates/content/actions/_builder-source-execution-claim.ts +124 -0
  55. package/corpus/templates/content/actions/_builder-source-execution-preservation.ts +21 -0
  56. package/corpus/templates/content/actions/_builder-source-timings.ts +57 -0
  57. package/corpus/templates/content/actions/_database-source-utils.ts +1071 -170
  58. package/corpus/templates/content/actions/_database-utils.ts +104 -13
  59. package/corpus/templates/content/actions/_local-file-documents.ts +53 -1
  60. package/corpus/templates/content/actions/_preview-document-draft.ts +31 -0
  61. package/corpus/templates/content/actions/_property-utils.ts +7 -1
  62. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +309 -0
  63. package/corpus/templates/content/actions/configure-document-property.ts +11 -0
  64. package/corpus/templates/content/actions/create-content-database.ts +11 -0
  65. package/corpus/templates/content/actions/create-document.ts +9 -0
  66. package/corpus/templates/content/actions/create-inline-content-database.ts +3 -0
  67. package/corpus/templates/content/actions/execute-builder-source-batch.ts +120 -49
  68. package/corpus/templates/content/actions/execute-builder-source-execution.ts +608 -264
  69. package/corpus/templates/content/actions/export-content-source.ts +1 -0
  70. package/corpus/templates/content/actions/get-content-database.ts +13 -1
  71. package/corpus/templates/content/actions/get-document.ts +12 -2
  72. package/corpus/templates/content/actions/get-preview-document-draft.ts +26 -0
  73. package/corpus/templates/content/actions/import-content-source.ts +6 -0
  74. package/corpus/templates/content/actions/list-documents.ts +3 -0
  75. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +550 -0
  76. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +112 -65
  77. package/corpus/templates/content/actions/prepare-builder-source-review.ts +463 -170
  78. package/corpus/templates/content/actions/preview-builder-source-review.ts +99 -0
  79. package/corpus/templates/content/actions/pull-document.ts +2 -0
  80. package/corpus/templates/content/actions/search-documents.ts +5 -1
  81. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +2 -2
  82. package/corpus/templates/content/actions/update-document.ts +16 -1
  83. package/corpus/templates/content/actions/update-preview-document-draft.ts +153 -0
  84. package/corpus/templates/content/actions/view-screen.ts +15 -2
  85. package/corpus/templates/content/app/components/editor/DescriptionField.tsx +121 -0
  86. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +14 -0
  87. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +250 -49
  88. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +57 -5
  89. package/corpus/templates/content/app/components/editor/body-hydration.ts +27 -0
  90. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2414 -589
  91. package/corpus/templates/content/app/components/editor/database/settings.tsx +13 -10
  92. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +358 -75
  93. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +124 -29
  94. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +2 -0
  95. package/corpus/templates/content/app/global.css +18 -1
  96. package/corpus/templates/content/app/hooks/use-content-database.ts +68 -0
  97. package/corpus/templates/content/app/hooks/use-documents.ts +53 -4
  98. package/corpus/templates/content/app/i18n/zh-TW.ts +30 -0
  99. package/corpus/templates/content/app/i18n-data.ts +339 -0
  100. package/corpus/templates/content/changelog/2026-07-10-builder-backed-preview-edits-are-preserved-when-article-bodi.md +6 -0
  101. package/corpus/templates/content/changelog/2026-07-10-builder-cms-sources-can-now-be-enabled-for-guarded-staged-or.md +6 -0
  102. package/corpus/templates/content/changelog/2026-07-13-builder-connection-setup-is-now-available-directly-from-conn.md +6 -0
  103. package/corpus/templates/content/changelog/2026-07-13-builder-reviews-now-hide-already-applied-writes-detect-nativ.md +6 -0
  104. package/corpus/templates/content/changelog/2026-07-13-required-builder-publishing-fields-can-now-be-added-directly.md +6 -0
  105. package/corpus/templates/content/changelog/2026-07-13-you-can-now-cancel-a-prepared-builder-update-safely-before-i.md +6 -0
  106. package/corpus/templates/content/changelog/2026-07-14-builder-account-connection-now-remains-available-when-branch.md +6 -0
  107. package/corpus/templates/content/changelog/2026-07-14-builder-backed-articles-now-preserve-fifth-and-sixth-level-h.md +6 -0
  108. package/corpus/templates/content/changelog/2026-07-14-builder-sources-now-review-and-publish-rich-article-updates-.md +6 -0
  109. package/corpus/templates/content/changelog/2026-07-14-builder-writes-now-stop-retries-when-reconciliation-is-needed.md +6 -0
  110. package/corpus/templates/content/changelog/2026-07-14-file-and-media-links-now-validate-before-saving.md +6 -0
  111. package/corpus/templates/content/changelog/2026-07-14-pages-and-databases-can-describe-themselves-with-guidance-fo.md +6 -0
  112. package/corpus/templates/content/changelog/2026-07-14-slack-follow-ups-now-retain-created-content-identity-and-per.md +6 -0
  113. package/corpus/templates/content/parity/matrix.md +27 -25
  114. package/corpus/templates/content/parity/matrix.ts +50 -2
  115. package/corpus/templates/content/server/db/schema.ts +51 -0
  116. package/corpus/templates/content/server/lib/document-context.ts +76 -0
  117. package/corpus/templates/content/server/lib/public-documents.ts +3 -0
  118. package/corpus/templates/content/server/plugins/auth.ts +1 -0
  119. package/corpus/templates/content/server/plugins/db.ts +59 -0
  120. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +5 -0
  121. package/corpus/templates/content/shared/api.ts +87 -1
  122. package/corpus/templates/content/shared/builder-mdx.ts +705 -15
  123. package/corpus/templates/content/shared/content-source.ts +7 -0
  124. package/corpus/templates/content/shared/nfm.ts +3 -3
  125. package/corpus/templates/content/shared/properties.ts +2 -0
  126. package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +52 -7
  127. package/corpus/templates/design/app/components/design/ReviewCommentsPanel.tsx +36 -4
  128. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  129. package/corpus/templates/design/app/i18n/ar-SA.ts +1 -0
  130. package/corpus/templates/design/app/i18n/de-DE.ts +1 -0
  131. package/corpus/templates/design/app/i18n/en-US.ts +1 -0
  132. package/corpus/templates/design/app/i18n/es-ES.ts +1 -0
  133. package/corpus/templates/design/app/i18n/fr-FR.ts +1 -0
  134. package/corpus/templates/design/app/i18n/hi-IN.ts +1 -0
  135. package/corpus/templates/design/app/i18n/ja-JP.ts +1 -0
  136. package/corpus/templates/design/app/i18n/ko-KR.ts +1 -0
  137. package/corpus/templates/design/app/i18n/pt-BR.ts +1 -0
  138. package/corpus/templates/design/app/i18n/zh-CN.ts +1 -0
  139. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  140. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +8 -0
  141. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +11 -0
  142. package/corpus/templates/design/changelog/2026-07-14-reviewers-can-now-pin-comments-from-the-canvas-toolbar-targe.md +6 -0
  143. package/corpus/templates/design/changelog/2026-07-15-viewer-comment-controls-now-sit-inside-the-read-only-notice-.md +6 -0
  144. package/corpus/templates/design/shared/review-anchor.ts +48 -0
  145. package/corpus/templates/plan/server/plan-mdx.ts +53 -7
  146. package/dist/a2a/artifact-response.d.ts +15 -0
  147. package/dist/a2a/artifact-response.d.ts.map +1 -1
  148. package/dist/a2a/artifact-response.js +204 -5
  149. package/dist/a2a/artifact-response.js.map +1 -1
  150. package/dist/agent/thread-data-builder.d.ts +6 -0
  151. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  152. package/dist/agent/thread-data-builder.js +71 -8
  153. package/dist/agent/thread-data-builder.js.map +1 -1
  154. package/dist/client/review/ReviewCommentComposer.d.ts +2 -1
  155. package/dist/client/review/ReviewCommentComposer.d.ts.map +1 -1
  156. package/dist/client/review/ReviewCommentComposer.js +3 -3
  157. package/dist/client/review/ReviewCommentComposer.js.map +1 -1
  158. package/dist/client/review/ReviewThreadPanel.d.ts +9 -1
  159. package/dist/client/review/ReviewThreadPanel.d.ts.map +1 -1
  160. package/dist/client/review/ReviewThreadPanel.js +6 -4
  161. package/dist/client/review/ReviewThreadPanel.js.map +1 -1
  162. package/dist/client/settings/SettingsPanel.d.ts +3 -0
  163. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  164. package/dist/client/settings/SettingsPanel.js +11 -4
  165. package/dist/client/settings/SettingsPanel.js.map +1 -1
  166. package/dist/client/settings/useBuilderStatus.d.ts +8 -4
  167. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  168. package/dist/client/settings/useBuilderStatus.js +19 -6
  169. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  170. package/dist/client/setup-connections/BuilderConnectCard.d.ts.map +1 -1
  171. package/dist/client/setup-connections/BuilderConnectCard.js +2 -5
  172. package/dist/client/setup-connections/BuilderConnectCard.js.map +1 -1
  173. package/dist/collab/awareness.d.ts +2 -2
  174. package/dist/collab/awareness.d.ts.map +1 -1
  175. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  176. package/dist/integrations/a2a-continuation-processor.js +72 -6
  177. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  178. package/dist/integrations/adapters/discord.d.ts.map +1 -1
  179. package/dist/integrations/adapters/discord.js +1 -0
  180. package/dist/integrations/adapters/discord.js.map +1 -1
  181. package/dist/integrations/adapters/email.d.ts.map +1 -1
  182. package/dist/integrations/adapters/email.js +2 -0
  183. package/dist/integrations/adapters/email.js.map +1 -1
  184. package/dist/integrations/adapters/google-docs.d.ts.map +1 -1
  185. package/dist/integrations/adapters/google-docs.js +2 -0
  186. package/dist/integrations/adapters/google-docs.js.map +1 -1
  187. package/dist/integrations/adapters/microsoft-teams.d.ts.map +1 -1
  188. package/dist/integrations/adapters/microsoft-teams.js +1 -0
  189. package/dist/integrations/adapters/microsoft-teams.js.map +1 -1
  190. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  191. package/dist/integrations/adapters/slack.js +21 -6
  192. package/dist/integrations/adapters/slack.js.map +1 -1
  193. package/dist/integrations/adapters/telegram.d.ts.map +1 -1
  194. package/dist/integrations/adapters/telegram.js +11 -2
  195. package/dist/integrations/adapters/telegram.js.map +1 -1
  196. package/dist/integrations/adapters/whatsapp.d.ts.map +1 -1
  197. package/dist/integrations/adapters/whatsapp.js +3 -0
  198. package/dist/integrations/adapters/whatsapp.js.map +1 -1
  199. package/dist/integrations/index.d.ts +1 -1
  200. package/dist/integrations/index.d.ts.map +1 -1
  201. package/dist/integrations/index.js.map +1 -1
  202. package/dist/integrations/types.d.ts +8 -2
  203. package/dist/integrations/types.d.ts.map +1 -1
  204. package/dist/integrations/types.js.map +1 -1
  205. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  206. package/dist/integrations/webhook-handler.js +40 -43
  207. package/dist/integrations/webhook-handler.js.map +1 -1
  208. package/dist/observability/routes.d.ts +5 -5
  209. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  210. package/dist/secrets/routes.d.ts +9 -9
  211. package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
  212. package/dist/server/agent-chat/action-filters-a2a.js +1 -0
  213. package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
  214. package/dist/server/auth.d.ts +1 -0
  215. package/dist/server/auth.d.ts.map +1 -1
  216. package/dist/server/auth.js +19 -2
  217. package/dist/server/auth.js.map +1 -1
  218. package/dist/server/builder-browser.d.ts +78 -0
  219. package/dist/server/builder-browser.d.ts.map +1 -1
  220. package/dist/server/builder-browser.js +246 -1
  221. package/dist/server/builder-browser.js.map +1 -1
  222. package/dist/server/core-routes-plugin.d.ts +41 -0
  223. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  224. package/dist/server/core-routes-plugin.js +260 -14
  225. package/dist/server/core-routes-plugin.js.map +1 -1
  226. package/docs/content/deployment.mdx +9 -7
  227. package/docs/content/locales/ar-SA/deployment.mdx +9 -7
  228. package/docs/content/locales/de-DE/deployment.mdx +9 -7
  229. package/docs/content/locales/es-ES/deployment.mdx +9 -7
  230. package/docs/content/locales/fr-FR/deployment.mdx +9 -7
  231. package/docs/content/locales/hi-IN/deployment.mdx +9 -7
  232. package/docs/content/locales/ja-JP/deployment.mdx +9 -7
  233. package/docs/content/locales/ko-KR/deployment.mdx +9 -7
  234. package/docs/content/locales/pt-BR/deployment.mdx +9 -7
  235. package/docs/content/locales/zh-CN/deployment.mdx +9 -7
  236. package/docs/content/locales/zh-TW/deployment.mdx +9 -7
  237. package/package.json +2 -2
@@ -5,6 +5,7 @@ import {
5
5
  useBuilderStatus,
6
6
  useCodeMode,
7
7
  useSession,
8
+ useT,
8
9
  } from "@agent-native/core/client";
9
10
  import {
10
11
  AlertDialog,
@@ -48,7 +49,6 @@ import {
48
49
  TooltipTrigger,
49
50
  } from "@agent-native/toolkit/ui/tooltip";
50
51
  import {
51
- BUILDER_CMS_SAFE_WRITE_MODEL,
52
52
  type BuilderCmsModelSummary,
53
53
  type ContentDatabaseItem,
54
54
  type ContentDatabaseResponse,
@@ -56,6 +56,8 @@ import {
56
56
  type ContentDatabaseSourceChangeSet,
57
57
  type ContentDatabaseSourceJoinRequest,
58
58
  type ContentDatabaseSourceReviewPayload,
59
+ type ContentDatabaseSourceWriteMode,
60
+ type ProcessBuilderBodyHydrationResponse,
59
61
  type SourceJoinSuggestion,
60
62
  type ContentDatabasePersonalViewOverrides,
61
63
  type ContentDatabaseView,
@@ -131,6 +133,7 @@ import {
131
133
  import { useQueryClient } from "@tanstack/react-query";
132
134
  import {
133
135
  useEffect,
136
+ useCallback,
134
137
  useMemo,
135
138
  useRef,
136
139
  useState,
@@ -148,6 +151,7 @@ import {
148
151
  useAddContentDatabaseSourceFieldProperty,
149
152
  useAttachContentDatabaseSource,
150
153
  useBuilderCmsModels,
154
+ useCancelPreparedBuilderSourceUpdate,
151
155
  useChangeContentDatabaseSourceRole,
152
156
  useContentDatabase,
153
157
  useContentDatabasePersonalView,
@@ -159,8 +163,10 @@ import {
159
163
  useDuplicateDatabaseItems,
160
164
  useExecuteBuilderSourceExecution,
161
165
  useMoveDatabaseItem,
166
+ useMaterializeBuilderRequiredFields,
162
167
  useNotionDatabaseSources,
163
168
  usePrepareBuilderSourceReview,
169
+ usePreviewBuilderSourceReview,
164
170
  useProcessBuilderBodyHydration,
165
171
  useRefreshContentDatabaseSource,
166
172
  useSetContentDatabaseSourceWriteMode,
@@ -173,9 +179,12 @@ import {
173
179
  useSetDocumentProperty,
174
180
  } from "@/hooks/use-document-properties";
175
181
  import {
182
+ isDocumentUpdateConflict,
176
183
  useDeleteDocument,
177
184
  useDocument,
178
185
  seedDatabaseItemDocumentCaches,
186
+ usePreviewDocumentDraft,
187
+ useUpdatePreviewDocumentDraft,
179
188
  useUpdateDocument,
180
189
  } from "@/hooks/use-documents";
181
190
  import { messagesByLocale } from "@/i18n-data";
@@ -188,6 +197,7 @@ import {
188
197
  isEffectivelyEmptyDocumentContent,
189
198
  previewBodyHydrationIsPending,
190
199
  previewBodyHydrationIsTerminalError,
200
+ previewDraftConflictsWithHydratedBody,
191
201
  shouldIgnorePreviewEmptyNormalization,
192
202
  } from "../body-hydration";
193
203
  import {
@@ -200,6 +210,7 @@ import {
200
210
  BuilderSourceReviewDialog,
201
211
  type BuilderReviewPublicationTransitions,
202
212
  } from "../database-sources/BuilderSourceReviewDialog";
213
+ import { DescriptionField } from "../DescriptionField";
203
214
  import { DocumentBlockFields } from "../DocumentBlockFields";
204
215
  import {
205
216
  AddProperty,
@@ -225,7 +236,8 @@ import {
225
236
  import { EmojiPicker } from "../EmojiPicker";
226
237
  import {
227
238
  createPreviewDocumentSaveController,
228
- skippedPreviewDocumentSave,
239
+ deferredPreviewDocumentSave,
240
+ type PreviewDocumentSaveAdapter,
229
241
  } from "../previewDocumentSaveController";
230
242
  import {
231
243
  acquirePreviewDocumentSaveController,
@@ -250,6 +262,26 @@ export interface DatabaseViewProps {
250
262
  const CONTENT_DATABASE_PAGE_SIZE = 100;
251
263
  const CONTENT_DATABASE_MAX_ITEM_LIMIT = 5_000;
252
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
+
253
285
  export type SortDirection = ContentDatabaseSortDirection;
254
286
  export type DatabaseSort = ContentDatabaseSort;
255
287
  export type FilterOperator = ContentDatabaseFilterOperator;
@@ -289,6 +321,32 @@ export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000;
289
321
  export type PersonalDatabaseViewOverrides =
290
322
  ContentDatabasePersonalViewOverrides;
291
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
+
292
350
  type DatabaseMessageKey = keyof (typeof messagesByLocale)["en-US"]["database"];
293
351
 
294
352
  export function dbText(
@@ -313,6 +371,96 @@ export function dbText(
313
371
  export type CreateDatabaseRowHandler = (
314
372
  title?: string,
315
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
+
316
464
  type DatabaseDragPreviewState =
317
465
  | {
318
466
  kind: "view";
@@ -514,6 +662,7 @@ function DatabaseTable({
514
662
  canEdit: boolean;
515
663
  isActive: boolean;
516
664
  }) {
665
+ const t = useT();
517
666
  const navigate = useNavigate();
518
667
  const queryClient = useQueryClient();
519
668
  const [databaseItemLimit, setDatabaseItemLimit] = useState(
@@ -528,8 +677,12 @@ function DatabaseTable({
528
677
  const processBuilderBodies = useProcessBuilderBodyHydration(document.id);
529
678
  const prepareBuilderReview = usePrepareBuilderSourceReview(document.id);
530
679
  const executeBuilderExecution = useExecuteBuilderSourceExecution(document.id);
680
+ const cancelPreparedBuilderUpdate = useCancelPreparedBuilderSourceUpdate(
681
+ document.id,
682
+ );
531
683
  const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id);
532
684
  const setProperty = useSetDocumentProperty(document.id, document.id);
685
+ const updateDatabaseDocument = useUpdateDocument();
533
686
  const updateView = useUpdateContentDatabaseView(document.id);
534
687
  // A deleted/missing database resolves to the unavailable union (no
535
688
  // `database` field) — treat it as no data; the inline-block wrapper owns
@@ -550,10 +703,19 @@ function DatabaseTable({
550
703
  const personalView = useContentDatabasePersonalView(databaseId);
551
704
  const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId);
552
705
  const source = data?.source ?? null;
553
- 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
+ );
554
714
  const [previewDocumentId, setPreviewDocumentId] = useState<string | null>(
555
715
  null,
556
716
  );
717
+ const [createdPreviewItem, setCreatedPreviewItem] =
718
+ useState<ContentDatabaseItem | null>(null);
557
719
  const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
558
720
  const [previewTitleFocusDocumentId, setPreviewTitleFocusDocumentId] =
559
721
  useState<string | null>(null);
@@ -562,6 +724,18 @@ function DatabaseTable({
562
724
  >(null);
563
725
  const [searchOpen, setSearchOpen] = useState(false);
564
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;
565
739
  const [settingsOpen, setSettingsOpen] = useState(false);
566
740
  const [inlineFilterControlsOpen, setInlineFilterControlsOpen] =
567
741
  useState(false);
@@ -576,11 +750,28 @@ function DatabaseTable({
576
750
  null,
577
751
  );
578
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);
579
761
  const [builderReviewResult, setBuilderReviewResult] =
580
762
  useState<ContentDatabaseSourceReviewPayload | null>(null);
581
763
  const [builderReviewCheckedAt, setBuilderReviewCheckedAt] = useState<
582
764
  string | null
583
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);
584
775
  const [settingsPanel, setSettingsPanel] =
585
776
  useState<DatabaseSettingsPanel>("main");
586
777
  const [viewConfig, setViewConfig] = useState<ContentDatabaseViewConfig>(
@@ -625,29 +816,22 @@ function DatabaseTable({
625
816
  const personalViewSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
626
817
  null,
627
818
  );
628
- const autoContinueBuilderSourceRef = useRef<string | null>(null);
629
- const builderContinuationWatchdogRef = useRef<{
630
- key: string | null;
631
- refires: number;
632
- }>({ 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);
633
825
  const [
634
- builderContinuationClientErrorKey,
635
- setBuilderContinuationClientErrorKey,
636
- ] = useState<string | null>(null);
637
- const autoPumpBuilderBodiesRef = useRef<string | null>(null);
638
- const [builderHydrationClientErrorKey, setBuilderHydrationClientErrorKey] =
639
- useState<string | null>(null);
640
- const [builderProgressHighWater, setBuilderProgressHighWater] = useState<{
641
- sourceId: string | null;
642
- fetchedCount: number;
643
- hydratedCount: number;
644
- rowsComplete: boolean;
645
- }>({
646
- sourceId: null,
647
- fetchedCount: 0,
648
- hydratedCount: 0,
649
- rowsComplete: false,
650
- });
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
+ >({});
651
835
  const previewStateRef = useRef<{
652
836
  documentId: string | null;
653
837
  visibleItems: ContentDatabaseItem[];
@@ -704,8 +888,11 @@ function DatabaseTable({
704
888
  activeFilters.length === 0 &&
705
889
  !databaseGroupProperty;
706
890
  const hasResultConstraints = !!searchQuery.trim() || activeFilters.length > 0;
707
- const previewItem =
708
- items.find((item) => item.document.id === previewDocumentId) ?? null;
891
+ const previewItem = databasePreviewItem(
892
+ items,
893
+ previewDocumentId,
894
+ createdPreviewItem,
895
+ );
709
896
  const previousPreviewItem = previewItem
710
897
  ? databaseItemPreviewNeighbor(
711
898
  screenVisibleItems,
@@ -727,18 +914,136 @@ function DatabaseTable({
727
914
  () => databaseSelectedItems(visibleItems, selectedItemIds),
728
915
  [visibleItems, selectedItemIds],
729
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;
730
937
  const builderReviewChangeSets = useMemo(
731
- () => builderReviewableChangeSets(source),
732
- [source],
938
+ () => builderReviewableChangeSets(builderReviewSource),
939
+ [builderReviewSource],
733
940
  );
734
941
  const builderReviewPreview = useMemo(
735
942
  () =>
736
- source?.sourceType === "builder-cms" && builderReviewChangeSets.length > 0
737
- ? buildClientBuilderReviewPayload(source, builderReviewChangeSets)
943
+ builderReviewSource && builderReviewChangeSets.length > 0
944
+ ? buildClientBuilderReviewPayload(
945
+ builderReviewSource,
946
+ builderReviewChangeSets,
947
+ )
738
948
  : null,
739
- [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],
740
1046
  );
741
- const activeBuilderReview = builderReviewResult ?? builderReviewPreview;
742
1047
 
743
1048
  useEffect(() => {
744
1049
  previewStateRef.current = {
@@ -754,191 +1059,125 @@ function DatabaseTable({
754
1059
  }, [visibleItems]);
755
1060
 
756
1061
  useEffect(() => {
757
- if (!source || source.sourceType !== "builder-cms") {
758
- autoPumpBuilderBodiesRef.current = null;
759
- setBuilderHydrationClientErrorKey(null);
760
- setBuilderProgressHighWater({
761
- sourceId: null,
762
- fetchedCount: 0,
763
- hydratedCount: 0,
764
- rowsComplete: false,
765
- });
766
- return;
767
- }
768
- const rawFetchedCount =
769
- typeof source.metadata.lastReadFetchedEntryCount === "number"
770
- ? source.metadata.lastReadFetchedEntryCount
771
- : typeof source.metadata.lastReadEntryCount === "number"
772
- ? source.metadata.lastReadEntryCount
773
- : 0;
774
- const fetchedCount =
775
- source.metadata.lastReadHasMore === false
776
- ? Math.max(rawFetchedCount, source.bodyHydration?.total ?? 0)
777
- : rawFetchedCount;
778
- const hydratedCount = source.bodyHydration?.hydrated ?? 0;
779
- const rowsComplete = source.metadata.lastReadHasMore === false;
780
- setBuilderProgressHighWater((current) => {
781
- if (current.sourceId !== source.id) {
782
- return {
783
- sourceId: source.id,
784
- fetchedCount,
785
- hydratedCount,
786
- rowsComplete,
787
- };
788
- }
789
- const next = {
790
- sourceId: source.id,
791
- fetchedCount: Math.max(current.fetchedCount, fetchedCount),
792
- hydratedCount: Math.max(current.hydratedCount, hydratedCount),
793
- rowsComplete: current.rowsComplete || rowsComplete,
794
- };
795
- return next.fetchedCount === current.fetchedCount &&
796
- next.hydratedCount === current.hydratedCount &&
797
- next.rowsComplete === current.rowsComplete
798
- ? current
799
- : next;
800
- });
801
- }, [source]);
1062
+ setBuilderProgressHighWater((current) =>
1063
+ databaseBuilderProgressHighWater(builderSources, current),
1064
+ );
1065
+ }, [builderSources]);
802
1066
 
803
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
+ );
804
1080
  if (
805
- !isActive ||
806
- !canEdit ||
807
- !source ||
808
- source.sourceType !== "builder-cms" ||
809
- sourceAddsDetails(source) ||
810
- refreshSource.isPending
811
- ) {
812
- return;
813
- }
814
- const sourceStatus = builderSourceRowFetchStatus(source);
815
- if (
816
- sourceStatus !== "fetching" ||
817
- source.metadata.lastReadHasMore !== true
1081
+ !runSourceRefresh(candidate.id, () =>
1082
+ setBuilderContinuationClientErrorKeys((current) =>
1083
+ addUniqueKey(current, continuationKey),
1084
+ ),
1085
+ )
818
1086
  ) {
819
- return;
1087
+ autoContinueBuilderSourceRef.current.delete(continuationKey);
820
1088
  }
821
- const continuationKey = builderSourceContinuationKey(source);
822
- if (!continuationKey) return;
823
- if (builderContinuationClientErrorKey === continuationKey) return;
824
- if (autoContinueBuilderSourceRef.current === continuationKey) return;
825
- autoContinueBuilderSourceRef.current = continuationKey;
826
- refreshSource.mutate(
827
- {
828
- documentId: document.id,
829
- sourceId: source.id,
830
- },
831
- {
832
- onError: () => setBuilderContinuationClientErrorKey(continuationKey),
833
- },
834
- );
835
1089
  }, [
836
- builderContinuationClientErrorKey,
1090
+ builderContinuationClientErrorKeys,
1091
+ builderSources,
837
1092
  canEdit,
838
- document.id,
839
1093
  isActive,
840
- refreshSource.mutate,
841
1094
  refreshSource.isPending,
842
- source,
1095
+ runSourceRefresh,
843
1096
  ]);
844
1097
 
845
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);
846
1108
  if (
847
- !isActive ||
848
- !canEdit ||
849
- !source ||
850
- !shouldPumpBuilderBodyHydration(
851
- source,
852
- processBuilderBodies.isPending,
853
- builderHydrationClientErrorKey,
854
- )
855
- ) {
856
- return;
857
- }
858
- const hydrationKey = builderBodyHydrationPumpKey(source);
859
- if (!hydrationKey || autoPumpBuilderBodiesRef.current === hydrationKey) {
860
- return;
861
- }
862
- autoPumpBuilderBodiesRef.current = hydrationKey;
863
- processBuilderBodies.mutate(
864
- { sourceId: source.id },
865
- {
1109
+ !runBuilderHydration(candidate.id, {
866
1110
  onSuccess: (result) => {
867
1111
  if (!builderBodyHydrationMutationMadeProgress(result)) {
868
- setBuilderHydrationClientErrorKey(hydrationKey);
1112
+ setBuilderHydrationClientErrorKeys((current) =>
1113
+ addUniqueKey(current, hydrationKey),
1114
+ );
869
1115
  }
870
1116
  },
871
- onError: () => setBuilderHydrationClientErrorKey(hydrationKey),
872
- },
873
- );
1117
+ onError: () =>
1118
+ setBuilderHydrationClientErrorKeys((current) =>
1119
+ addUniqueKey(current, hydrationKey),
1120
+ ),
1121
+ })
1122
+ ) {
1123
+ autoPumpBuilderBodiesRef.current.delete(hydrationKey);
1124
+ }
874
1125
  }, [
875
- builderHydrationClientErrorKey,
1126
+ builderHydrationClientErrorKeys,
1127
+ builderSources,
876
1128
  canEdit,
877
1129
  isActive,
878
1130
  processBuilderBodies.isPending,
879
- processBuilderBodies.mutate,
880
- source,
1131
+ runBuilderHydration,
881
1132
  ]);
882
1133
 
883
1134
  useEffect(() => {
884
- const continuationKey =
885
- source?.sourceType === "builder-cms"
886
- ? builderSourceContinuationKey(source)
887
- : null;
888
- if (
889
- !isActive ||
890
- !canEdit ||
891
- !source ||
892
- source.sourceType !== "builder-cms" ||
893
- sourceAddsDetails(source) ||
894
- refreshSource.isPending ||
895
- builderSourceRowFetchStatus(source) !== "fetching" ||
896
- source.metadata.lastReadHasMore !== true ||
897
- !continuationKey ||
898
- builderContinuationClientErrorKey === continuationKey
899
- ) {
900
- return;
901
- }
902
- if (builderContinuationWatchdogRef.current.key !== continuationKey) {
903
- builderContinuationWatchdogRef.current = {
904
- key: continuationKey,
905
- refires: 0,
906
- };
907
- }
908
- const refireDelay = builderSourceContinuationWatchdogDelay(
909
- 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
+ },
910
1143
  );
1144
+ if (!candidate) return;
1145
+ const continuationKey = builderSourceContinuationKey(candidate);
1146
+ if (!continuationKey) return;
1147
+ const refires =
1148
+ builderContinuationWatchdogRef.current.get(continuationKey) ?? 0;
911
1149
  const timer = window.setTimeout(() => {
912
- const watchdog = builderContinuationWatchdogRef.current;
913
- if (watchdog.key !== continuationKey) return;
914
1150
  if (
915
- builderSourceContinuationWatchdogDecision(watchdog.refires) !== "refire"
916
- )
1151
+ builderSourceContinuationWatchdogDecision(refires) !== "refire" ||
1152
+ refreshSource.isPending
1153
+ ) {
917
1154
  return;
918
- builderContinuationWatchdogRef.current = {
919
- key: continuationKey,
920
- refires: watchdog.refires + 1,
921
- };
922
- autoContinueBuilderSourceRef.current = null;
923
- refreshSource.mutate(
924
- {
925
- documentId: document.id,
926
- sourceId: source.id,
927
- },
928
- {
929
- onError: () => setBuilderContinuationClientErrorKey(continuationKey),
930
- },
931
- );
932
- }, 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));
933
1173
  return () => window.clearTimeout(timer);
934
1174
  }, [
935
- builderContinuationClientErrorKey,
1175
+ builderContinuationClientErrorKeys,
1176
+ builderSources,
936
1177
  canEdit,
937
- document.id,
938
1178
  isActive,
939
- refreshSource.mutate,
940
1179
  refreshSource.isPending,
941
- source,
1180
+ runSourceRefresh,
942
1181
  ]);
943
1182
 
944
1183
  useEffect(() => {
@@ -1043,19 +1282,20 @@ function DatabaseTable({
1043
1282
  }
1044
1283
 
1045
1284
  function prioritizeBuilderBodyHydrationForItem(item: ContentDatabaseItem) {
1046
- const sourceId = item.document.databaseMembership?.sourceId ?? source?.id;
1047
- if (!sourceId) return;
1048
- const builderSource =
1049
- sources.find((candidate) => candidate.id === sourceId) ?? source;
1050
- if (builderSource?.sourceType !== "builder-cms") return;
1285
+ const builderSource = databaseBuilderHydrationSourceForItem(item, sources);
1286
+ if (!builderSource) return;
1287
+ const sourceId = builderSource.id;
1051
1288
  const hydration =
1052
1289
  item.bodyHydration ?? item.document.databaseMembership?.bodyHydration;
1053
1290
  if (hydration && !databaseItemBodyHydrationIsPending(item)) return;
1054
- processBuilderBodies.mutate({
1291
+ runBuilderHydration(
1055
1292
  sourceId,
1056
- documentId: item.document.id,
1057
- limit: 1,
1058
- });
1293
+ {},
1294
+ {
1295
+ documentId: item.document.id,
1296
+ limit: 1,
1297
+ },
1298
+ );
1059
1299
  }
1060
1300
 
1061
1301
  async function createRow(
@@ -1086,14 +1326,21 @@ function DatabaseTable({
1086
1326
  });
1087
1327
  return null;
1088
1328
  }
1089
- const createdItem = response.items.find(
1090
- (item) => item.id === response.createdItemId,
1091
- );
1092
- 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);
1093
1340
  setPreviewDocumentId(createdItem.document.id);
1094
1341
  setPreviewTitleFocusDocumentId(createdItem.document.id);
1095
1342
  }
1096
- if (createdItem && options.focusInlineTitle) {
1343
+ if (createdItem && options.focusInlineTitle && !needsPreview) {
1097
1344
  setInlineTitleFocusDocumentId(createdItem.document.id);
1098
1345
  }
1099
1346
  return createdItem ?? null;
@@ -1368,73 +1615,376 @@ function DatabaseTable({
1368
1615
  updateActiveView((view) => ({ ...view, hideEmptyGroups }));
1369
1616
  }
1370
1617
 
1371
- async function handleBuilderReviewPush(
1372
- transitions: BuilderReviewPublicationTransitions = {},
1373
- ) {
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);
1374
1633
  setBuilderReviewResult(null);
1375
1634
  setBuilderReviewCheckedAt(null);
1376
- const scopedChangeSetIds =
1377
- activeBuilderReview && activeBuilderReview.rows.length > 0
1378
- ? activeBuilderReview.rows.map((row) => row.changeSetId)
1379
- : undefined;
1380
- try {
1381
- const prepared = await prepareBuilderReview.mutateAsync({
1382
- documentId: document.id,
1383
- sourceId: source?.id,
1384
- changeSetIds: scopedChangeSetIds,
1385
- 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"),
1386
1691
  });
1387
- 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
+ );
1388
1712
 
1389
- if (
1390
- nextReview.liveWritesEnabled &&
1391
- nextReview.result.status === "validated"
1392
- ) {
1393
- const executableRows = builderReviewExecutableRows(nextReview);
1394
- let executedResponse: ContentDatabaseResponse | null = null;
1395
- for (const row of executableRows) {
1396
- if (!row.execution?.idempotencyKey) continue;
1397
- const transition = transitions[row.changeSetId];
1398
- executedResponse = await executeBuilderExecution.mutateAsync({
1399
- documentId: document.id,
1400
- changeSetId: row.changeSetId,
1401
- idempotencyKey: row.execution.idempotencyKey,
1402
- pushModeConfirmation: nextReview.pushMode,
1403
- publicationTransition: transition?.publicationTransition,
1404
- confirmUnpublish: transition?.confirmUnpublish,
1405
- });
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;
1406
1736
  }
1407
- const executedSource = executedResponse?.source ?? null;
1408
- if (executedSource) {
1409
- const reviewedIds = new Set(
1410
- nextReview.rows.map((row) => row.changeSetId),
1411
- );
1412
- const reviewedChangeSets = executedSource.changeSets.filter(
1413
- (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.",
1414
1746
  );
1415
- if (reviewedChangeSets.length > 0) {
1416
- nextReview = buildClientBuilderReviewPayload(
1417
- executedSource,
1418
- reviewedChangeSets,
1419
- );
1420
- }
1421
1747
  }
1422
- }
1423
1748
 
1424
- setBuilderReviewResult(nextReview);
1425
- setBuilderReviewCheckedAt(new Date().toISOString());
1426
- toast.success(
1427
- nextReview.result.status === "succeeded"
1428
- ? "Builder update pushed"
1429
- : "Builder update checked",
1430
- {
1431
- 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,
1432
1908
  },
1433
1909
  );
1434
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);
1435
1938
  toast.error(dbText("builderUpdateFailed"), {
1436
- description:
1437
- 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,
1438
1988
  });
1439
1989
  }
1440
1990
  }
@@ -1578,6 +2128,19 @@ function DatabaseTable({
1578
2128
 
1579
2129
  return (
1580
2130
  <div className="mt-4 min-w-0 w-full max-w-[calc(100vw-var(--content-sidebar-width,0px)-1.5rem)]">
2131
+ <DescriptionField
2132
+ description={document.description}
2133
+ canEdit={canEdit}
2134
+ label={t("editor.properties.description")}
2135
+ placeholder={t("editor.properties.addDatabaseDescription")}
2136
+ className="mb-3"
2137
+ onSave={(description) =>
2138
+ updateDatabaseDocument.mutateAsync({
2139
+ id: document.id,
2140
+ description,
2141
+ })
2142
+ }
2143
+ />
1581
2144
  <div className="mb-1 flex min-h-8 flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-1">
1582
2145
  <DatabaseViewTabs
1583
2146
  viewConfig={viewConfig}
@@ -1793,41 +2356,40 @@ function DatabaseTable({
1793
2356
  onSaveForEveryone={() => void savePersonalQueryForEveryone()}
1794
2357
  />
1795
2358
 
1796
- <BuilderSourceContinuationBar
1797
- source={source}
1798
- canEdit={canEdit}
1799
- pending={refreshSource.isPending}
1800
- clientError={
1801
- source
1802
- ? builderContinuationClientErrorKey ===
1803
- builderSourceContinuationKey(source)
1804
- : false
1805
- }
1806
- progressHighWater={builderProgressHighWater}
1807
- onRetry={() => {
1808
- if (!source) return;
1809
- const continuationKey = builderSourceContinuationKey(source);
1810
- setBuilderContinuationClientErrorKey(null);
1811
- autoContinueBuilderSourceRef.current = null;
1812
- builderContinuationWatchdogRef.current = {
1813
- key: continuationKey,
1814
- refires: 0,
1815
- };
1816
- refreshSource.mutate(
1817
- {
1818
- documentId: document.id,
1819
- sourceId: source.id,
1820
- },
1821
- {
1822
- onError: () => {
1823
- if (continuationKey) {
1824
- setBuilderContinuationClientErrorKey(continuationKey);
1825
- }
1826
- },
1827
- },
1828
- );
1829
- }}
1830
- />
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
+ })}
1831
2393
 
1832
2394
  {activeView.type === "form" ? (
1833
2395
  <DatabaseFormView
@@ -1846,7 +2408,7 @@ function DatabaseTable({
1846
2408
  groupProperty={boardGroupProperty}
1847
2409
  databaseDocumentId={document.id}
1848
2410
  canEdit={canEdit}
1849
- isLoading={isDatabaseInitialLoading}
2411
+ isLoading={isDatabaseViewLoading}
1850
2412
  isCreating={addItem.isPending || setProperty.isPending}
1851
2413
  hasActiveConstraints={!!searchQuery || activeFilters.length > 0}
1852
2414
  isMoving={setProperty.isPending}
@@ -1874,7 +2436,7 @@ function DatabaseTable({
1874
2436
  items={visibleItems}
1875
2437
  databaseDocumentId={document.id}
1876
2438
  canEdit={canEdit}
1877
- isLoading={isDatabaseInitialLoading}
2439
+ isLoading={isDatabaseViewLoading}
1878
2440
  isCreating={addItem.isPending}
1879
2441
  activeFilters={activeFilters}
1880
2442
  hasSearch={!!searchQuery}
@@ -1897,7 +2459,7 @@ function DatabaseTable({
1897
2459
  items={visibleItems}
1898
2460
  databaseDocumentId={document.id}
1899
2461
  canEdit={canEdit}
1900
- isLoading={isDatabaseInitialLoading}
2462
+ isLoading={isDatabaseViewLoading}
1901
2463
  isCreating={addItem.isPending}
1902
2464
  activeFilters={activeFilters}
1903
2465
  hasSearch={!!searchQuery}
@@ -1920,7 +2482,7 @@ function DatabaseTable({
1920
2482
  items={visibleItems}
1921
2483
  databaseDocumentId={document.id}
1922
2484
  canEdit={canEdit}
1923
- isLoading={isDatabaseInitialLoading}
2485
+ isLoading={isDatabaseViewLoading}
1924
2486
  isCreating={addItem.isPending || setProperty.isPending}
1925
2487
  activeFilters={activeFilters}
1926
2488
  hasSearch={!!searchQuery}
@@ -1946,7 +2508,7 @@ function DatabaseTable({
1946
2508
  items={visibleItems}
1947
2509
  databaseDocumentId={document.id}
1948
2510
  canEdit={canEdit}
1949
- isLoading={isDatabaseInitialLoading}
2511
+ isLoading={isDatabaseViewLoading}
1950
2512
  isCreating={addItem.isPending || setProperty.isPending}
1951
2513
  activeFilters={activeFilters}
1952
2514
  hasSearch={!!searchQuery}
@@ -1980,7 +2542,7 @@ function DatabaseTable({
1980
2542
  sources={sources}
1981
2543
  databaseDocumentId={document.id}
1982
2544
  canEdit={canEdit}
1983
- isLoading={isDatabaseInitialLoading}
2545
+ isLoading={isDatabaseViewLoading}
1984
2546
  isCreating={addItem.isPending}
1985
2547
  columnWidths={columnWidths}
1986
2548
  sorts={sorts}
@@ -2027,7 +2589,7 @@ function DatabaseTable({
2027
2589
  />
2028
2590
  )}
2029
2591
 
2030
- {hasMoreItems ? (
2592
+ {hasMoreItems && !isSearchExpansionPending ? (
2031
2593
  <div className="flex items-center justify-center border-t border-border/45 py-3">
2032
2594
  <Button
2033
2595
  type="button"
@@ -2063,6 +2625,7 @@ function DatabaseTable({
2063
2625
  if (!open) {
2064
2626
  setPreviewDocumentId(null);
2065
2627
  setPreviewTitleFocusDocumentId(null);
2628
+ setCreatedPreviewItem(null);
2066
2629
  }
2067
2630
  }}
2068
2631
  onPreviewItem={(item) => {
@@ -2080,6 +2643,7 @@ function DatabaseTable({
2080
2643
  panel={settingsPanel}
2081
2644
  documentId={document.id}
2082
2645
  canEdit={canEdit}
2646
+ canManage={document.canManage === true}
2083
2647
  activeView={activeView}
2084
2648
  properties={orderedProperties}
2085
2649
  items={items}
@@ -2125,15 +2689,11 @@ function DatabaseTable({
2125
2689
  onDisconnectSecondary={(sourceId) =>
2126
2690
  disconnectSource.mutate({ documentId: document.id, sourceId })
2127
2691
  }
2128
- onRefreshSource={(sourceId) =>
2129
- refreshSource.mutate({
2130
- documentId: document.id,
2131
- sourceId,
2132
- })
2133
- }
2134
- onHydrateBuilderBodies={(sourceId) =>
2135
- processBuilderBodies.mutate({ sourceId })
2136
- }
2692
+ onRefreshSource={(sourceId) => {
2693
+ const targetSourceId = sourceId ?? source?.id;
2694
+ if (targetSourceId) runSourceRefresh(targetSourceId);
2695
+ }}
2696
+ onHydrateBuilderBodies={(sourceId) => runBuilderHydration(sourceId)}
2137
2697
  onDisconnectSource={(sourceId) =>
2138
2698
  disconnectSource.mutate(
2139
2699
  {
@@ -2161,30 +2721,29 @@ function DatabaseTable({
2161
2721
  },
2162
2722
  )
2163
2723
  }
2164
- onReviewBuilderUpdate={() => {
2165
- setBuilderReviewResult(null);
2166
- setBuilderReviewCheckedAt(null);
2167
- setBuilderReviewOpen(true);
2724
+ onReviewBuilderUpdate={(sourceId) => {
2725
+ openBuilderReview(sourceId);
2168
2726
  }}
2169
- onSetBuilderLiveWrites={(enabled) =>
2727
+ onSetBuilderLiveWrites={(settings) =>
2170
2728
  setSourceWriteMode.mutate(
2171
2729
  {
2172
2730
  documentId: document.id,
2173
- liveWritesEnabled: enabled,
2174
- allowedWriteModes: enabled ? ["autosave"] : [],
2731
+ sourceId: settings.sourceId,
2732
+ writeMode: settings.writeMode,
2733
+ allowPublicationTransitions:
2734
+ settings.writeMode === "publish_updates" &&
2735
+ settings.allowPublicationTransitions === true,
2175
2736
  },
2176
2737
  {
2177
2738
  onSuccess: () => {
2178
- toast.success(
2179
- enabled
2180
- ? "Builder live writes enabled"
2181
- : "Builder live writes disabled",
2182
- {
2183
- description: enabled
2184
- ? "Only autosave writes to the Agent Native test collection can run."
2185
- : "Push will return to local validation only.",
2186
- },
2187
- );
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
+ });
2188
2747
  },
2189
2748
  onError: (error) => {
2190
2749
  toast.error(dbText("builderWriteModeWasNotChanged"), {
@@ -2203,8 +2762,10 @@ function DatabaseTable({
2203
2762
  disconnectSource.isPending ||
2204
2763
  prepareBuilderReview.isPending ||
2205
2764
  executeBuilderExecution.isPending ||
2765
+ cancelPreparedBuilderUpdate.isPending ||
2206
2766
  setSourceWriteMode.isPending
2207
2767
  }
2768
+ sourcePendingOperations={sourcePendingOperations}
2208
2769
  onViewTypeChange={(type) =>
2209
2770
  setViewConfig(updateDatabaseViewType(viewConfig, activeView.id, type))
2210
2771
  }
@@ -2225,14 +2786,36 @@ function DatabaseTable({
2225
2786
  <BuilderSourceReviewDialog
2226
2787
  open={builderReviewOpen}
2227
2788
  review={activeBuilderReview}
2228
- source={source}
2789
+ source={builderReviewSource}
2229
2790
  canEdit={canEdit}
2230
2791
  pending={
2231
- 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)
2232
2802
  }
2233
2803
  checkedAt={builderReviewCheckedAt}
2234
- onClose={() => setBuilderReviewOpen(false)}
2235
- 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
+ }}
2236
2819
  />
2237
2820
 
2238
2821
  {!isDatabaseInitialLoading ? (
@@ -2524,6 +3107,518 @@ export function databaseBuilderBulkUpdateSource(
2524
3107
  );
2525
3108
  }
2526
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
+
2527
3622
  export function databaseBulkScalarInputState(
2528
3623
  type: DocumentPropertyType,
2529
3624
  input: string,
@@ -2782,19 +3877,44 @@ function DatabaseItemPreview({
2782
3877
  const deleteDocument = useDeleteDocument();
2783
3878
  const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId);
2784
3879
  const { data: document, isLoading } = useDocument(item.document.id);
3880
+ const { data: persistedPreviewDraft } = usePreviewDocumentDraft(
3881
+ item.document.id,
3882
+ );
3883
+ const updatePreviewDraft = useUpdatePreviewDocumentDraft();
2785
3884
  const previewDocument = document ?? item.document;
2786
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;
2787
3899
  const canEdit = document?.canEdit ?? item.document.canEdit ?? true;
2788
3900
  const canManage = document?.canManage ?? item.document.canManage ?? false;
2789
- const bodyHydrationPending = previewBodyHydrationIsPending({
2790
- item,
2791
- document,
2792
- });
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
+ });
2793
3912
  const bodyHydrationError = previewBodyHydrationIsTerminalError({
2794
3913
  item,
2795
3914
  document,
2796
3915
  });
2797
- const previewCanEdit = canEdit && !bodyHydrationPending;
3916
+ const previewCanEdit =
3917
+ canEdit && !bodyHydrationPending && !activeBodyDraftConflict;
2798
3918
  const location = useLocation();
2799
3919
  // Seed the displayed title/content from a RETAINED dirty controller's pending
2800
3920
  // edit if one exists for this doc (reopen-before-evict), so an unsaved peek
@@ -2839,63 +3959,249 @@ function DatabaseItemPreview({
2839
3959
  queryClientRef.current = queryClient;
2840
3960
  const bodyHydrationPendingRef = useRef(bodyHydrationPending);
2841
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;
2842
3969
  // Doc ids that have been deleted in this peek's lifetime. A pending save must
2843
3970
  // never resurrect a deleted document, so dispatch is suppressed for these.
2844
3971
  const deletedIdsRef = useRef<Set<string>>(new Set());
2845
3972
 
2846
3973
  const documentId = item.document.id;
2847
3974
 
2848
- // Build the factory for THIS row's controller. It closes over the stable
2849
- // component-scoped refs (updateDocument, queryClient, deletedIds), so the
2850
- // freshest mutation impl is always used while the controller's save TARGET
2851
- // (`documentId`) is fixed by the registry key.
2852
- const makeController = () =>
2853
- createPreviewDocumentSaveController({
2854
- documentId,
2855
- initial: {
2856
- title: item.document.title,
2857
- content: item.document.content,
2858
- loadedUpdatedAt: item.document.updatedAt,
2859
- loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
2860
- item.document.content,
2861
- ),
2862
- },
2863
- save: (id, payload) =>
2864
- new Promise((resolve, reject) => {
2865
- // A just-deleted doc must not be re-dispatched (resurrection guard).
2866
- if (deletedIdsRef.current.has(id)) {
2867
- resolve(undefined);
2868
- return;
2869
- }
2870
- if (bodyHydrationPendingRef.current) {
2871
- resolve(skippedPreviewDocumentSave());
2872
- return;
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);
2873
4026
  }
2874
- updateDocumentRef.current.mutate(
2875
- {
2876
- id,
2877
- title: payload.title,
2878
- content: payload.content,
2879
- loadedUpdatedAt: payload.loadedUpdatedAt,
2880
- loadedContentWasEmpty: payload.loadedContentWasEmpty,
2881
- },
2882
- { onSuccess: () => resolve(undefined), onError: reject },
2883
- );
2884
- }),
2885
- onSaved: () => {
2886
- void queryClientRef.current.invalidateQueries({
2887
- queryKey: contentDatabaseQueryKey(databaseDocumentId),
2888
- });
2889
- void queryClientRef.current.invalidateQueries({
2890
- queryKey: ["action", "list-documents"],
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
+ },
2891
4041
  });
2892
- },
2893
- onError: (err) => {
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) => {
2894
4085
  toast.error(dbText("failedToSavePagePreview"), {
2895
4086
  description:
2896
- err instanceof Error ? err.message : dbText("somethingWentWrong"),
4087
+ error instanceof Error
4088
+ ? error.message
4089
+ : dbText("somethingWentWrong"),
2897
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
+
4193
+ const makeController = () =>
4194
+ createPreviewDocumentSaveController({
4195
+ documentId,
4196
+ initial: {
4197
+ title: item.document.title,
4198
+ content: item.document.content,
4199
+ loadedUpdatedAt: item.document.updatedAt,
4200
+ loadedContentWasEmpty: isEffectivelyEmptyDocumentContent(
4201
+ item.document.content,
4202
+ ),
2898
4203
  },
4204
+ ...makeSaveAdapter(),
2899
4205
  });
2900
4206
 
2901
4207
  // Acquire the controller for the current row, and release it on row-switch /
@@ -2929,8 +4235,21 @@ function DatabaseItemPreview({
2929
4235
  saveControllerRef.current = acquirePreviewDocumentSaveController(
2930
4236
  documentId,
2931
4237
  makeController,
4238
+ (controller) => controller.replaceSaveAdapter(makeSaveAdapter()),
2932
4239
  );
2933
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
+ }
2934
4253
  saveControllerRef.current = null;
2935
4254
  releasePreviewDocumentSaveController(documentId);
2936
4255
  };
@@ -2940,6 +4259,58 @@ function DatabaseItemPreview({
2940
4259
  // eslint-disable-next-line react-hooks/exhaustive-deps
2941
4260
  }, [documentId]);
2942
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
+
2943
4314
  // Sync displayed state to the current row, and adopt fresh server content
2944
4315
  // (e.g. an agent edit) as the controller's new confirmed baseline. mark()
2945
4316
  // touches only THIS row's controller — never another row's — because the
@@ -2973,12 +4344,31 @@ function DatabaseItemPreview({
2973
4344
  controller.lastSaved.loadedContentWasEmpty === true &&
2974
4345
  isEffectivelyEmptyDocumentContent(controller.pending.content) &&
2975
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
+ }));
2976
4359
  // Only adopt the server's title/content — into BOTH the displayed editor
2977
4360
  // state and the controller baseline — when the user hasn't typed something
2978
4361
  // newer on this row. If a dirty in-progress edit exists, preserve it: don't
2979
4362
  // clobber the visible text (the controller already holds the unsaved edit,
2980
4363
  // so nothing is lost, but the editor must keep showing what the user typed).
2981
- 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
+ ) {
2982
4372
  setLocalTitle(nextTitle);
2983
4373
  setLocalContent(nextContent);
2984
4374
  controller?.mark(serverPayload);
@@ -3001,6 +4391,36 @@ function DatabaseItemPreview({
3001
4391
  item.document.updatedAt,
3002
4392
  ]);
3003
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
+
3004
4424
  useEffect(() => {
3005
4425
  if (!focusTitle || !previewCanEdit || isLoading || !document) return;
3006
4426
 
@@ -3016,7 +4436,9 @@ function DatabaseItemPreview({
3016
4436
  function handleTitleChange(nextTitle: string) {
3017
4437
  setLocalTitle(nextTitle);
3018
4438
  if (!previewCanEdit || !document) return;
3019
- saveControllerRef.current?.changeTitle(nextTitle);
4439
+ const controller = saveControllerRef.current;
4440
+ controller?.changeTitle(nextTitle);
4441
+ if (controller) scheduleDraftWrite(controller);
3020
4442
  }
3021
4443
 
3022
4444
  function handleContentChange(nextContent: string) {
@@ -3031,7 +4453,31 @@ function DatabaseItemPreview({
3031
4453
  }
3032
4454
  setLocalContent(nextContent);
3033
4455
  if (!previewCanEdit || !document) return;
3034
- 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);
3035
4481
  }
3036
4482
 
3037
4483
  function handleIconChange(nextIcon: string | null) {
@@ -3326,6 +4772,37 @@ function DatabaseItemPreview({
3326
4772
 
3327
4773
  return (
3328
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}
3329
4806
  {bodyHydrationError ? (
3330
4807
  <BuilderBodySyncingNotice
3331
4808
  title={dbText("builderBodySyncFailedNotice")}
@@ -5390,7 +6867,12 @@ type PendingSourceCandidate = {
5390
6867
  type SourceNavStep =
5391
6868
  | { kind: "provider"; providerId: "builder" }
5392
6869
  | { kind: "space"; spaceId: string; spaceName: string }
5393
- | { kind: "model"; model: BuilderCmsModelSummary }
6870
+ | {
6871
+ kind: "model";
6872
+ model?: BuilderCmsModelSummary;
6873
+ sourceId?: string;
6874
+ sourceName?: string;
6875
+ }
5394
6876
  | { kind: "addSource" }
5395
6877
  | { kind: "secondarySource"; sourceId: string; sourceName: string }
5396
6878
  | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
@@ -5405,7 +6887,7 @@ function sourceNavTitle(stack: SourceNavStep[]): string {
5405
6887
  if (top.kind === "secondarySource") return top.sourceName;
5406
6888
  if (top.kind === "keyConfirm") return dbText("matchExistingItemsToDetails");
5407
6889
  if (top.kind === "fieldPicker") return dbText("chooseFields");
5408
- return top.model.displayName;
6890
+ return top.model?.displayName ?? top.sourceName ?? "Builder";
5409
6891
  }
5410
6892
 
5411
6893
  // The Builder "B" brand mark (first glyph of the wordmark), drawn with
@@ -5453,6 +6935,7 @@ function DatabaseSettingsPanelSheet({
5453
6935
  panel,
5454
6936
  documentId,
5455
6937
  canEdit,
6938
+ canManage,
5456
6939
  activeView,
5457
6940
  properties,
5458
6941
  items,
@@ -5472,6 +6955,7 @@ function DatabaseSettingsPanelSheet({
5472
6955
  onReviewBuilderUpdate,
5473
6956
  onSetBuilderLiveWrites,
5474
6957
  sourceActionPending,
6958
+ sourcePendingOperations,
5475
6959
  onViewTypeChange,
5476
6960
  onWrapCellsChange,
5477
6961
  onOpenPagesInChange,
@@ -5486,6 +6970,7 @@ function DatabaseSettingsPanelSheet({
5486
6970
  panel: DatabaseSettingsPanel;
5487
6971
  documentId: string;
5488
6972
  canEdit: boolean;
6973
+ canManage: boolean;
5489
6974
  activeView: ContentDatabaseView;
5490
6975
  properties: DocumentProperty[];
5491
6976
  items: ContentDatabaseItem[];
@@ -5512,9 +6997,10 @@ function DatabaseSettingsPanelSheet({
5512
6997
  onRefreshSource: (sourceId?: string) => void;
5513
6998
  onHydrateBuilderBodies: (sourceId: string) => void;
5514
6999
  onDisconnectSource: (sourceId?: string) => void;
5515
- onReviewBuilderUpdate: () => void;
5516
- onSetBuilderLiveWrites: (enabled: boolean) => void;
7000
+ onReviewBuilderUpdate: (sourceId: string) => void;
7001
+ onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void;
5517
7002
  sourceActionPending: boolean;
7003
+ sourcePendingOperations: DatabaseSourcePendingOperations;
5518
7004
  onViewTypeChange: (type: ContentDatabaseViewType) => void;
5519
7005
  onWrapCellsChange: (wrapCells: boolean) => void;
5520
7006
  onOpenPagesInChange: (openPagesIn: ContentDatabaseOpenPagesIn) => void;
@@ -5598,6 +7084,7 @@ function DatabaseSettingsPanelSheet({
5598
7084
  documentId={documentId}
5599
7085
  itemCount={items.length}
5600
7086
  canEdit={canEdit}
7087
+ canManage={canManage}
5601
7088
  nav={sourceNavStack}
5602
7089
  onNavPush={(step) => setSourceNavStack((stack) => [...stack, step])}
5603
7090
  onNavReplace={setSourceNavStack}
@@ -5614,6 +7101,7 @@ function DatabaseSettingsPanelSheet({
5614
7101
  onReviewBuilderUpdate={onReviewBuilderUpdate}
5615
7102
  onSetBuilderLiveWrites={onSetBuilderLiveWrites}
5616
7103
  sourceActionPending={sourceActionPending}
7104
+ sourcePendingOperations={sourcePendingOperations}
5617
7105
  />
5618
7106
  ) : panel === "layout" ? (
5619
7107
  <DatabaseSettingsLayoutPanel
@@ -5728,6 +7216,9 @@ export function builderReviewableChangeSets(
5728
7216
  return source.changeSets.filter(
5729
7217
  (changeSet) =>
5730
7218
  changeSet.direction === "outbound" &&
7219
+ !changeSet.executions.some(
7220
+ (execution) => execution.state === "succeeded",
7221
+ ) &&
5731
7222
  (changeSet.state === "pending_push" ||
5732
7223
  changeSet.state === "staged_revision" ||
5733
7224
  changeSet.state === "approved"),
@@ -5753,12 +7244,33 @@ function maxSourceReviewRisk(
5753
7244
 
5754
7245
  export function builderReviewExecutableRows(
5755
7246
  review: ContentDatabaseSourceReviewPayload,
7247
+ selectedChangeSetIds: ReadonlySet<string> = new Set(
7248
+ review.rows.map((row) => row.changeSetId),
7249
+ ),
5756
7250
  ) {
5757
- if (!review.liveWritesEnabled || review.result.status !== "validated") {
5758
- return [];
5759
- }
7251
+ if (!review.liveWritesEnabled) return [];
5760
7252
  return review.rows.filter(
5761
- (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
5762
7274
  );
5763
7275
  }
5764
7276
 
@@ -5766,24 +7278,67 @@ export function builderSourceLiveWriteControlState(
5766
7278
  source: ContentDatabaseSource | null,
5767
7279
  ) {
5768
7280
  const isBuilderSource = source?.sourceType === "builder-cms";
5769
- const safeTarget =
5770
- isBuilderSource && source?.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL;
5771
- 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";
5772
7290
  return {
5773
- safeTarget,
7291
+ safeTarget: isBuilderSource,
5774
7292
  enabled,
5775
- 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,
5776
7302
  actionLabel: enabled ? "Disable" : "Enable",
5777
7303
  description: enabled
5778
- ? "Enabled for autosave writes to the Agent Native test collection."
5779
- : safeTarget
5780
- ? "Off by default. Enable only when you are ready to send autosave writes to the Agent Native test collection."
5781
- : isBuilderSource
5782
- ? "Unavailable here; live writes are locked to the Agent Native test collection."
5783
- : "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.",
5784
7310
  };
5785
7311
  }
5786
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
+
5787
7342
  export function buildClientBuilderReviewPayload(
5788
7343
  source: ContentDatabaseSource,
5789
7344
  changeSets: ContentDatabaseSourceChangeSet[],
@@ -5809,6 +7364,7 @@ export function buildClientBuilderReviewPayload(
5809
7364
  (field) => field.localFieldKey === "title",
5810
7365
  );
5811
7366
  const proposedTitle = titleChange?.proposedValue;
7367
+ const effect = resolveBuilderCmsWriteEffect({ source, changeSet });
5812
7368
 
5813
7369
  return {
5814
7370
  changeSetId: changeSet.id,
@@ -5818,12 +7374,14 @@ export function buildClientBuilderReviewPayload(
5818
7374
  typeof proposedTitle === "string" && proposedTitle.trim()
5819
7375
  ? proposedTitle
5820
7376
  : sourceRow?.sourceDisplayKey || "Untitled",
7377
+ targetEntryId:
7378
+ effect === "create_draft" ? null : (sourceRow?.sourceRowId ?? null),
5821
7379
  fieldChanges: changeSet.fieldChanges,
5822
7380
  bodyChange: changeSet.bodyChange,
5823
7381
  riskLevel: changeSet.riskLevel,
5824
7382
  riskReasons: changeSet.riskReasons,
5825
7383
  conflictState: changeSet.conflictState,
5826
- effect: resolveBuilderCmsWriteEffect({ source, changeSet }),
7384
+ effect,
5827
7385
  execution: latestExecution,
5828
7386
  };
5829
7387
  });
@@ -5846,19 +7404,22 @@ export function buildClientBuilderReviewPayload(
5846
7404
  executionStates.length > 0 &&
5847
7405
  executionStates.every((state) => state === "succeeded")
5848
7406
  ? "succeeded"
5849
- : executionStates.includes("failed")
5850
- ? "failed"
5851
- : executionStates.includes("running")
5852
- ? "running"
5853
- : statuses.some((status) => status.status === "stale")
5854
- ? "stale"
5855
- : statuses.some((status) => status.status === "blocked")
5856
- ? "blocked"
5857
- : statuses.some((status) => status.status === "validated")
5858
- ? "validated"
5859
- : 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")
5860
7419
  ? "validated"
5861
- : "write_disabled";
7420
+ : source.capabilities.liveWritesEnabled
7421
+ ? "validated"
7422
+ : "write_disabled";
5862
7423
 
5863
7424
  return {
5864
7425
  summary:
@@ -5880,19 +7441,21 @@ export function buildClientBuilderReviewPayload(
5880
7441
  ? "Pushed to Builder and reconciled locally."
5881
7442
  : resultStatus === "failed"
5882
7443
  ? "Builder push failed. The change remains retryable."
5883
- : resultStatus === "running"
5884
- ? "Builder push is running."
5885
- : resultStatus === "validated"
5886
- ? source.capabilities.liveWritesEnabled
5887
- ? hasExecutionEvidence
5888
- ? "Push checked successfully. Ready to send to Builder."
5889
- : "Ready to send to Builder."
5890
- : "Push checked successfully. Nothing was sent to Builder."
5891
- : resultStatus === "blocked"
5892
- ? "Push needs attention before anything can be sent to Builder."
5893
- : resultStatus === "stale"
5894
- ? "Push needs a fresh review because the plan changed."
5895
- : "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.",
5896
7459
  },
5897
7460
  };
5898
7461
  }
@@ -5903,6 +7466,7 @@ function DatabaseSettingsSourcePanel({
5903
7466
  documentId,
5904
7467
  itemCount,
5905
7468
  canEdit,
7469
+ canManage,
5906
7470
  nav,
5907
7471
  onNavPush,
5908
7472
  onNavReplace,
@@ -5916,12 +7480,14 @@ function DatabaseSettingsSourcePanel({
5916
7480
  onReviewBuilderUpdate,
5917
7481
  onSetBuilderLiveWrites,
5918
7482
  sourceActionPending,
7483
+ sourcePendingOperations,
5919
7484
  }: {
5920
7485
  source: ContentDatabaseSource | null;
5921
7486
  sources: ContentDatabaseSource[];
5922
7487
  documentId: string;
5923
7488
  itemCount: number;
5924
7489
  canEdit: boolean;
7490
+ canManage: boolean;
5925
7491
  nav: SourceNavStep[];
5926
7492
  onNavPush: (step: SourceNavStep) => void;
5927
7493
  onNavReplace: (stack: SourceNavStep[]) => void;
@@ -5942,26 +7508,13 @@ function DatabaseSettingsSourcePanel({
5942
7508
  onRefreshSource: (sourceId?: string) => void;
5943
7509
  onHydrateBuilderBodies: (sourceId: string) => void;
5944
7510
  onDisconnectSource: (sourceId?: string) => void;
5945
- onReviewBuilderUpdate: () => void;
5946
- onSetBuilderLiveWrites: (enabled: boolean) => void;
7511
+ onReviewBuilderUpdate: (sourceId: string) => void;
7512
+ onSetBuilderLiveWrites: (settings: BuilderSourceWriteSettingsInput) => void;
5947
7513
  sourceActionPending: boolean;
7514
+ sourcePendingOperations: DatabaseSourcePendingOperations;
5948
7515
  }) {
5949
- const outboundChangeSets =
5950
- source?.changeSets.filter(
5951
- (changeSet) => changeSet.direction === "outbound",
5952
- ) ?? [];
5953
- const reviewableBuilderChangeSets = outboundChangeSets.filter(
5954
- (changeSet) =>
5955
- changeSet.state === "pending_push" ||
5956
- changeSet.state === "staged_revision" ||
5957
- changeSet.state === "approved",
5958
- );
5959
- const conflictChangeSets =
5960
- source?.changeSets.filter(
5961
- (changeSet) => changeSet.conflictState === "source_changed",
5962
- ) ?? [];
5963
7516
  const { isCodeMode } = useCodeMode();
5964
- const isBuilderSource = source?.sourceType === "builder-cms";
7517
+ const builderSources = databaseAttachedBuilderSources(sources, source);
5965
7518
  const builderStatus = useBuilderStatus();
5966
7519
  const builderConfigured = builderStatus.status?.configured === true;
5967
7520
  const builderOrgName = builderStatus.status?.orgName ?? null;
@@ -5980,10 +7533,6 @@ function DatabaseSettingsSourcePanel({
5980
7533
  void builderStatus.refetch();
5981
7534
  },
5982
7535
  });
5983
- const builderSyncFailed =
5984
- isBuilderSource &&
5985
- (source?.syncState === "error" || Boolean(source?.lastError));
5986
-
5987
7536
  const top = nav[nav.length - 1];
5988
7537
 
5989
7538
  // ── Sources list (root) ───────────────────────────────────────────────
@@ -5994,9 +7543,21 @@ function DatabaseSettingsSourcePanel({
5994
7543
  sources={sources}
5995
7544
  builderConfigured={builderConfigured}
5996
7545
  builderSpaceLabel={builderSpaceLabel}
5997
- reviewableCount={reviewableBuilderChangeSets.length}
5998
- onOpenBuilder={() =>
5999
- 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
+ )
6000
7561
  }
6001
7562
  onOpenNotion={() => onNavPush({ kind: "addSource" })}
6002
7563
  onOpenSecondary={(secondary) =>
@@ -6200,22 +7761,28 @@ function DatabaseSettingsSourcePanel({
6200
7761
  if (top.kind === "space") {
6201
7762
  return (
6202
7763
  <BuilderSpaceModelsView
6203
- attachedModelName={
6204
- isBuilderSource ? (source?.sourceTable ?? null) : null
6205
- }
6206
- 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
+ }}
6207
7771
  />
6208
7772
  );
6209
7773
  }
6210
7774
 
6211
7775
  // ── Model leaf ────────────────────────────────────────────────────────
6212
7776
  const model = top.model;
6213
- const isAttachedModel =
6214
- Boolean(source) && isBuilderSource && source?.sourceTable === model.name;
7777
+ const selectedBuilderSource = databaseAttachedBuilderSource(sources, source, {
7778
+ sourceId: top.sourceId,
7779
+ modelName: model?.name,
7780
+ });
6215
7781
 
6216
7782
  // Unattached model → the attach affordance (the model is already chosen by
6217
7783
  // drilling in, so there's no model picker here).
6218
- if (!isAttachedModel || !source) {
7784
+ if (!selectedBuilderSource) {
7785
+ if (!model) return null;
6219
7786
  return (
6220
7787
  <div className="grid min-w-0 gap-3">
6221
7788
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
@@ -6294,41 +7861,91 @@ function DatabaseSettingsSourcePanel({
6294
7861
  );
6295
7862
  }
6296
7863
 
6297
- // 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.
6298
7918
  return (
6299
7919
  <div className="grid min-w-0 gap-4">
6300
7920
  <>
6301
7921
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
6302
7922
  <div className="flex min-w-0 items-center justify-between gap-2">
6303
- <span className="truncate font-medium" title={source.sourceName}>
6304
- {source.sourceName}
7923
+ <span
7924
+ className="truncate font-medium"
7925
+ title={selectedSource.sourceName}
7926
+ >
7927
+ {selectedSource.sourceName}
6305
7928
  </span>
6306
7929
  <div className="flex shrink-0 items-center gap-1">
6307
- {isBuilderSource ? (
6308
- source.capabilities.liveWritesEnabled ? (
6309
- <span className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-foreground">
6310
- <IconPencil className="size-3" />
6311
- {dbText("liveWritesOn")}
6312
- </span>
6313
- ) : (
6314
- <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">
6315
- <IconLock className="size-3" />
6316
- {dbText("readOnly")}
6317
- </span>
6318
- )
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>
6319
7935
  ) : (
6320
- <span className="rounded-full border border-border px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
6321
- {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")}
6322
7939
  </span>
6323
7940
  )}
6324
- {isBuilderSource && !builderSyncFailed ? (
7941
+ {!builderSyncFailed ? (
6325
7942
  <Button
6326
7943
  type="button"
6327
7944
  variant="ghost"
6328
7945
  size="sm"
6329
7946
  className="h-7 gap-1 px-2 text-xs"
6330
- disabled={!canEdit || sourceActionPending}
6331
- onClick={() => onRefreshSource(source.id)}
7947
+ disabled={!canEdit || selectedSourceRefreshControlPending}
7948
+ onClick={() => onRefreshSource(selectedSource.id)}
6332
7949
  >
6333
7950
  <IconRefresh className="size-3" />
6334
7951
  {dbText("refreshSource")}
@@ -6341,109 +7958,186 @@ function DatabaseSettingsSourcePanel({
6341
7958
  <button
6342
7959
  type="button"
6343
7960
  className="inline-flex items-center gap-1 text-destructive hover:underline disabled:opacity-60"
6344
- disabled={!canEdit || sourceActionPending}
6345
- onClick={() => onRefreshSource(source.id)}
7961
+ disabled={!canEdit || selectedSourceRefreshControlPending}
7962
+ onClick={() => onRefreshSource(selectedSource.id)}
6346
7963
  >
6347
7964
  <IconRefresh className="size-3" />
6348
7965
  {dbText("couldntSyncRetry")}
6349
7966
  </button>
6350
- ) : isBuilderSource ? (
7967
+ ) : (
6351
7968
  [
6352
7969
  builderConfigured ? (builderSpaceLabel ?? "Connected") : null,
6353
- source.lastRefreshedAt
7970
+ selectedSource.lastRefreshedAt
6354
7971
  ? `synced ${
6355
- formatRelativeSyncTime(source.lastRefreshedAt) ??
6356
- source.freshness
7972
+ formatRelativeSyncTime(selectedSource.lastRefreshedAt) ??
7973
+ selectedSource.freshness
6357
7974
  }`
6358
- : source.freshness,
6359
- typeof source.metadata.lastReadFetchedEntryCount === "number"
7975
+ : selectedSource.freshness,
7976
+ typeof selectedSource.metadata.lastReadFetchedEntryCount ===
7977
+ "number"
6360
7978
  ? dbText("builderRowsFetched", {
6361
- count: source.metadata.lastReadFetchedEntryCount,
7979
+ count: selectedSource.metadata.lastReadFetchedEntryCount,
6362
7980
  })
6363
7981
  : null,
6364
- builderSourceRowFetchStatus(source) === "error"
7982
+ builderSourceRowFetchStatus(selectedSource) === "error"
6365
7983
  ? dbText("builderRowsFetchFailed")
6366
- : builderSourceRowFetchStatus(source) === "fetching"
7984
+ : builderSourceRowFetchStatus(selectedSource) === "fetching"
6367
7985
  ? dbText("builderRowsFetchingMore")
6368
7986
  : null,
6369
7987
  ]
6370
7988
  .filter(Boolean)
6371
7989
  .join(" · ")
6372
- ) : (
6373
- `Local snapshot · ${source.freshness}`
6374
7990
  )}
6375
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>
6376
8072
  </div>
6377
8073
 
6378
- {reviewableBuilderChangeSets.length > 0 ||
6379
- conflictChangeSets.length > 0 ? (
6380
- <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-muted/30 p-3">
6381
- <div className="flex items-center justify-between gap-3">
6382
- <div className="min-w-0">
6383
- <div className="text-sm font-medium">
6384
- {conflictChangeSets.length > 0
6385
- ? `${conflictChangeSets.length} change${
6386
- conflictChangeSets.length === 1 ? "" : "s"
6387
- } need review`
6388
- : `${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${
6389
8084
  reviewableBuilderChangeSets.length === 1 ? "" : "s"
6390
- } ready to push`}
6391
- </div>
6392
- <div className="mt-0.5 break-words text-xs text-muted-foreground">
6393
- {dbText("reviewBeforeTheyReachBuilder")}
6394
- </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")}
6395
8090
  </div>
6396
- <Button
6397
- type="button"
6398
- size="sm"
6399
- className="shrink-0"
6400
- disabled={!canEdit || sourceActionPending}
6401
- onClick={onReviewBuilderUpdate}
6402
- >
6403
- <IconCheck className="mr-1.5 size-3.5" />
6404
- {dbText("reviewDiff")}
6405
- </Button>
6406
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>
6407
8102
  </div>
6408
- ) : null}
8103
+ </div>
6409
8104
 
6410
- {isBuilderSource && source.bodyHydration ? (
8105
+ {selectedSource.bodyHydration ? (
6411
8106
  <BuilderBodyHydrationCard
6412
- source={source}
8107
+ source={selectedSource}
6413
8108
  canEdit={canEdit}
6414
- pending={sourceActionPending}
6415
- onHydrate={() => onHydrateBuilderBodies(source.id)}
8109
+ pending={selectedSourceHydrationControlPending}
8110
+ onHydrate={() => onHydrateBuilderBodies(selectedSource.id)}
6416
8111
  />
6417
8112
  ) : null}
6418
8113
 
8114
+ <BuilderRequiredFieldsCard
8115
+ documentId={documentId}
8116
+ source={selectedSource}
8117
+ canEdit={canEdit}
8118
+ pending={selectedSourceMutationPending}
8119
+ />
8120
+
6419
8121
  {isCodeMode ? (
6420
8122
  <>
6421
8123
  <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3 text-sm">
6422
- <div className="font-medium">
6423
- {source.sourceType === "builder-cms"
6424
- ? "Local Builder changes"
6425
- : "Local outbound changes"}
6426
- </div>
8124
+ <div className="font-medium">{dbText("builderChanges")}</div>
6427
8125
  <div className="text-xs text-muted-foreground">
6428
- {source.sourceType === "builder-cms"
6429
- ? source.capabilities.liveWritesEnabled
6430
- ? "Local edits can be reviewed and sent through the guarded Builder autosave path."
6431
- : "Local edits can be staged as a Builder save revision/autosave record. Live Builder writes are disabled."
6432
- : "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."}
6433
8129
  </div>
6434
8130
  <div className="grid min-w-0 gap-2">
6435
8131
  {outboundChangeSets.slice(0, 6).map((changeSet) => (
6436
8132
  <SourceChangeSetReviewCard
6437
8133
  key={changeSet.id}
6438
8134
  changeSet={changeSet}
6439
- source={source}
8135
+ source={selectedSource}
6440
8136
  />
6441
8137
  ))}
6442
8138
  {outboundChangeSets.length === 0 ? (
6443
8139
  <div className="text-xs text-muted-foreground">
6444
- {source.sourceType === "builder-cms"
6445
- ? "No pending local Builder changes yet. Rename a source-backed row to see a local outbound diff."
6446
- : "No local outbound changes yet."}
8140
+ {dbText("noPendingChangesEditASourceBackedRow")}
6447
8141
  </div>
6448
8142
  ) : null}
6449
8143
  </div>
@@ -6452,33 +8146,33 @@ function DatabaseSettingsSourcePanel({
6452
8146
  ) : null}
6453
8147
 
6454
8148
  <SourceRoleCard
6455
- source={source}
8149
+ source={selectedSource}
6456
8150
  canAddDetails={sources.some(
6457
- (item) => item.id !== source.id && !sourceAddsDetails(item),
8151
+ (item) => item.id !== selectedSource.id && !sourceAddsDetails(item),
6458
8152
  )}
6459
8153
  canEdit={canEdit}
6460
- pending={sourceActionPending}
8154
+ pending={selectedSourceRoleControlPending}
6461
8155
  onAddDetails={() =>
6462
8156
  onNavPush({
6463
8157
  kind: "keyConfirm",
6464
8158
  candidate: {
6465
- sourceType: source.sourceType,
6466
- sourceName: source.sourceName,
6467
- sourceTable: source.sourceTable,
6468
- displayName: source.sourceName,
6469
- existingSourceId: source.id,
8159
+ sourceType: selectedSource.sourceType,
8160
+ sourceName: selectedSource.sourceName,
8161
+ sourceTable: selectedSource.sourceTable,
8162
+ displayName: selectedSource.sourceName,
8163
+ existingSourceId: selectedSource.id,
6470
8164
  },
6471
8165
  })
6472
8166
  }
6473
8167
  onAddItems={async () => {
6474
- await onChangeSourceRole(source.id, "items");
8168
+ await onChangeSourceRole(selectedSource.id, "items");
6475
8169
  onNavReplace([]);
6476
8170
  }}
6477
8171
  onChooseFields={() =>
6478
8172
  onNavPush({
6479
8173
  kind: "fieldPicker",
6480
- sourceId: source.id,
6481
- sourceName: source.sourceName,
8174
+ sourceId: selectedSource.id,
8175
+ sourceName: selectedSource.sourceName,
6482
8176
  })
6483
8177
  }
6484
8178
  />
@@ -6495,10 +8189,10 @@ function DatabaseSettingsSourcePanel({
6495
8189
  size="sm"
6496
8190
  variant="outline"
6497
8191
  className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
6498
- disabled={!canEdit || sourceActionPending}
6499
- onClick={() => onDisconnectSource(source.id)}
8192
+ disabled={!canEdit || selectedSourceDisconnectControlPending}
8193
+ onClick={() => onDisconnectSource(selectedSource.id)}
6500
8194
  >
6501
- {sourceActionPending ? (
8195
+ {selectedSourceDisconnectControlPending ? (
6502
8196
  <Spinner className="mr-1 size-3.5" />
6503
8197
  ) : (
6504
8198
  <IconX className="mr-1 size-3.5" />
@@ -6529,7 +8223,7 @@ function SourcesListView({
6529
8223
  builderConfigured: boolean;
6530
8224
  builderSpaceLabel: string | null;
6531
8225
  reviewableCount: number;
6532
- onOpenBuilder: () => void;
8226
+ onOpenBuilder: (source?: ContentDatabaseSource) => void;
6533
8227
  onOpenNotion: () => void;
6534
8228
  onOpenSecondary: (source: ContentDatabaseSource) => void;
6535
8229
  onAddSource: () => void;
@@ -6564,7 +8258,7 @@ function SourcesListView({
6564
8258
  connected.metadata.federation?.role === "secondary"
6565
8259
  ? () => onOpenSecondary(connected)
6566
8260
  : connected.sourceType === "builder-cms"
6567
- ? onOpenBuilder
8261
+ ? () => onOpenBuilder(connected)
6568
8262
  : undefined
6569
8263
  }
6570
8264
  disabled={
@@ -7194,12 +8888,7 @@ function BuilderSourceContinuationBar({
7194
8888
  canEdit: boolean;
7195
8889
  pending: boolean;
7196
8890
  clientError: boolean;
7197
- progressHighWater: {
7198
- sourceId: string | null;
7199
- fetchedCount: number;
7200
- hydratedCount: number;
7201
- rowsComplete: boolean;
7202
- };
8891
+ progressHighWater?: BuilderSourceProgressHighWater;
7203
8892
  onRetry: () => void;
7204
8893
  }) {
7205
8894
  if (
@@ -7211,8 +8900,7 @@ function BuilderSourceContinuationBar({
7211
8900
  }
7212
8901
  const rowsComplete =
7213
8902
  source.metadata.lastReadHasMore === false ||
7214
- (progressHighWater.sourceId === source.id &&
7215
- progressHighWater.rowsComplete);
8903
+ progressHighWater?.rowsComplete === true;
7216
8904
  const rawStatus = clientError ? "error" : builderSourceRowFetchStatus(source);
7217
8905
  const status = rowsComplete && rawStatus === "fetching" ? null : rawStatus;
7218
8906
  const bodyHydration = source.bodyHydration;
@@ -7225,10 +8913,7 @@ function BuilderSourceContinuationBar({
7225
8913
  const hydratedCount = bodyHydration
7226
8914
  ? builderBodyHydrationDisplayHydratedCount({
7227
8915
  summary: bodyHydration,
7228
- highWaterCount:
7229
- progressHighWater.sourceId === source.id
7230
- ? progressHighWater.hydratedCount
7231
- : 0,
8916
+ highWaterCount: progressHighWater?.hydratedCount ?? 0,
7232
8917
  })
7233
8918
  : 0;
7234
8919
  const fetchedCount =
@@ -7236,9 +8921,7 @@ function BuilderSourceContinuationBar({
7236
8921
  ? Math.max(
7237
8922
  rawFetchedCount ?? 0,
7238
8923
  bodyHydration.total,
7239
- progressHighWater.sourceId === source.id
7240
- ? progressHighWater.fetchedCount
7241
- : 0,
8924
+ progressHighWater?.fetchedCount ?? 0,
7242
8925
  )
7243
8926
  : rawFetchedCount;
7244
8927
  const hasMore = source.metadata.lastReadHasMore === true && !rowsComplete;
@@ -7485,6 +9168,105 @@ function sourceFieldIconType(
7485
9168
  return "text";
7486
9169
  }
7487
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
+
7488
9270
  function SourceDetailsFieldPicker({
7489
9271
  documentId,
7490
9272
  source,
@@ -7569,7 +9351,6 @@ function SourceDetailsFieldPicker({
7569
9351
  });
7570
9352
  }
7571
9353
  };
7572
-
7573
9354
  return (
7574
9355
  <div className="grid min-w-0 gap-3">
7575
9356
  <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3">
@@ -7580,6 +9361,13 @@ function SourceDetailsFieldPicker({
7580
9361
  {dbText("pickDetailsBecomeColumns")}
7581
9362
  </div>
7582
9363
  </div>
9364
+ <BuilderRequiredFieldsCard
9365
+ documentId={documentId}
9366
+ source={source}
9367
+ canEdit={canEdit}
9368
+ pending={pending}
9369
+ onDone={onDone}
9370
+ />
7583
9371
  {fields.length === 0 ? (
7584
9372
  <div className="break-words text-xs text-muted-foreground">
7585
9373
  {dbText("allAvailableDetailFieldsAlreadyVisible")}
@@ -7650,10 +9438,10 @@ function SourceDetailsFieldPicker({
7650
9438
  // A Builder space's data models, as drill-in rows. The attached model (if any)
7651
9439
  // is marked; selecting a row opens that model's leaf.
7652
9440
  function BuilderSpaceModelsView({
7653
- attachedModelName,
9441
+ attachedModelNames,
7654
9442
  onOpenModel,
7655
9443
  }: {
7656
- attachedModelName: string | null;
9444
+ attachedModelNames: string[];
7657
9445
  onOpenModel: (model: BuilderCmsModelSummary) => void;
7658
9446
  }) {
7659
9447
  const modelsQuery = useBuilderCmsModels(true);
@@ -7661,22 +9449,27 @@ function BuilderSpaceModelsView({
7661
9449
  const [query, setQuery] = useState("");
7662
9450
 
7663
9451
  if (modelsQuery.isLoading) {
7664
- if (attachedModelName) {
9452
+ if (attachedModelNames.length > 0) {
7665
9453
  return (
7666
9454
  <div className="grid min-w-0 gap-2">
7667
9455
  <div className="grid min-w-0 gap-1.5">
7668
9456
  <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
7669
9457
  {dbText("alreadyAttached")}
7670
9458
  </div>
7671
- <div className="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm">
7672
- <span className="flex min-w-0 items-center gap-2">
7673
- <IconLayoutGrid className="size-4 shrink-0 text-muted-foreground" />
7674
- <span className="min-w-0 truncate">{attachedModelName}</span>
7675
- </span>
7676
- <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
7677
- attached
7678
- </span>
7679
- </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
+ ))}
7680
9473
  </div>
7681
9474
  <div className="flex min-w-0 items-center gap-2 px-2 text-xs text-muted-foreground">
7682
9475
  <Spinner className="size-3.5" />
@@ -7750,15 +9543,16 @@ function BuilderSpaceModelsView({
7750
9543
  model.displayName.toLowerCase().includes(normalizedQuery) ||
7751
9544
  model.name.toLowerCase().includes(normalizedQuery);
7752
9545
  const filtered = models.filter(matchesQuery);
7753
- const attachedModels = filtered.filter(
7754
- (model) => attachedModelName === model.name,
9546
+ const attachedModelNameSet = new Set(attachedModelNames);
9547
+ const attachedModels = filtered.filter((model) =>
9548
+ attachedModelNameSet.has(model.name),
7755
9549
  );
7756
9550
  const otherModels = filtered.filter(
7757
- (model) => attachedModelName !== model.name,
9551
+ (model) => !attachedModelNameSet.has(model.name),
7758
9552
  );
7759
9553
 
7760
9554
  const renderRow = (model: BuilderCmsModelSummary) => {
7761
- const isAttached = attachedModelName === model.name;
9555
+ const isAttached = attachedModelNameSet.has(model.name);
7762
9556
  return (
7763
9557
  <button
7764
9558
  key={model.id}
@@ -12129,8 +13923,10 @@ export function databaseBulkMultiSelectFilteredOptions(
12129
13923
  ) {
12130
13924
  const normalizedQuery = query.trim().toLowerCase();
12131
13925
  if (!normalizedQuery) return options;
12132
- return options.filter((option) =>
12133
- option.name.trim().toLowerCase().includes(normalizedQuery),
13926
+ return options.filter(
13927
+ (option) =>
13928
+ option.name.trim().toLowerCase().includes(normalizedQuery) ||
13929
+ option.description?.trim().toLowerCase().includes(normalizedQuery),
12134
13930
  );
12135
13931
  }
12136
13932
 
@@ -13511,7 +15307,14 @@ function DatabaseBulkOptionValueEditor({
13511
15307
  )
13512
15308
  }
13513
15309
  >
13514
- <DatabaseBulkOptionPill option={option} />
15310
+ <span className="min-w-0 flex-1">
15311
+ <DatabaseBulkOptionPill option={option} />
15312
+ {option.description ? (
15313
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
15314
+ {option.description}
15315
+ </span>
15316
+ ) : null}
15317
+ </span>
13515
15318
  {presence.presentInAll ? (
13516
15319
  <IconCheck className="size-4 shrink-0 text-muted-foreground" />
13517
15320
  ) : presence.presentInAny ? (
@@ -13560,7 +15363,14 @@ function DatabaseBulkOptionValueEditor({
13560
15363
  disabled={disabled}
13561
15364
  onClick={() => void onApply({ kind: "set", value: option.id })}
13562
15365
  >
13563
- <DatabaseBulkOptionPill option={option} />
15366
+ <span className="min-w-0 flex-1">
15367
+ <DatabaseBulkOptionPill option={option} />
15368
+ {option.description ? (
15369
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
15370
+ {option.description}
15371
+ </span>
15372
+ ) : null}
15373
+ </span>
13564
15374
  </button>
13565
15375
  ))}
13566
15376
  </div>
@@ -15858,6 +17668,24 @@ function DatabaseGroupedTableSection({
15858
17668
  );
15859
17669
  }
15860
17670
 
17671
+ export function databaseItemPropertyForColumn(
17672
+ item: ContentDatabaseItem,
17673
+ columnProperty: DocumentProperty,
17674
+ ): DocumentProperty {
17675
+ const itemProperty = item.properties.find(
17676
+ (candidate) => candidate.definition.id === columnProperty.definition.id,
17677
+ );
17678
+ if (!itemProperty) return columnProperty;
17679
+
17680
+ // Row payloads own values, not schema meaning. Always pair the row's value
17681
+ // with the database's current canonical definition so a recently described
17682
+ // option cannot remain invisible in an older row snapshot.
17683
+ return {
17684
+ ...itemProperty,
17685
+ definition: columnProperty.definition,
17686
+ };
17687
+ }
17688
+
15861
17689
  function DatabaseTableRow({
15862
17690
  item,
15863
17691
  properties,
@@ -15942,10 +17770,7 @@ function DatabaseTableRow({
15942
17770
  onPreview={onPreview}
15943
17771
  />
15944
17772
  {properties.map((property) => {
15945
- const itemProperty =
15946
- item.properties.find(
15947
- (candidate) => candidate.definition.id === property.definition.id,
15948
- ) ?? property;
17773
+ const itemProperty = databaseItemPropertyForColumn(item, property);
15949
17774
  const bodyCellHydrationPending =
15950
17775
  itemProperty.definition.type === "blocks" &&
15951
17776
  databaseItemBodyHydrationIsPending(item);