@agent-native/core 0.80.5 → 0.80.7

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 (76) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/create.ts +1 -0
  5. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  6. package/corpus/core/src/sharing/registry.ts +17 -0
  7. package/corpus/core/src/templates/default/pnpm-workspace.yaml +1 -0
  8. package/corpus/core/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
  9. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  10. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  11. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  12. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  13. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  14. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  15. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  16. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  17. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  18. package/corpus/templates/content/AGENTS.md +45 -44
  19. package/corpus/templates/content/README.md +16 -0
  20. package/corpus/templates/content/actions/_builder-cms-read-client.ts +125 -0
  21. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +28 -6
  22. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +238 -69
  23. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +125 -43
  24. package/corpus/templates/content/actions/_database-source-utils.ts +746 -97
  25. package/corpus/templates/content/actions/_database-utils.ts +17 -14
  26. package/corpus/templates/content/actions/_federation-join.ts +14 -2
  27. package/corpus/templates/content/actions/_join-suggestion.ts +89 -14
  28. package/corpus/templates/content/actions/_local-file-documents.ts +2 -1
  29. package/corpus/templates/content/actions/attach-content-database-source.ts +124 -5
  30. package/corpus/templates/content/actions/bind-content-database-source-field.ts +256 -0
  31. package/corpus/templates/content/actions/change-content-database-source-role.ts +420 -0
  32. package/corpus/templates/content/actions/create-document.ts +8 -0
  33. package/corpus/templates/content/actions/execute-builder-source-batch.ts +282 -0
  34. package/corpus/templates/content/actions/execute-builder-source-execution.ts +220 -8
  35. package/corpus/templates/content/actions/get-content-database.ts +10 -2
  36. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +19 -2
  37. package/corpus/templates/content/actions/prepare-builder-source-review.ts +47 -15
  38. package/corpus/templates/content/actions/refresh-content-database-source.ts +11 -4
  39. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +6 -2
  40. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +32 -12
  41. package/corpus/templates/content/actions/stage-builder-revision.ts +14 -10
  42. package/corpus/templates/content/actions/validate-builder-source-execution.ts +25 -3
  43. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1520 -553
  44. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +150 -1
  45. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +647 -66
  46. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +543 -204
  47. package/corpus/templates/content/app/hooks/use-content-database.ts +62 -0
  48. package/corpus/templates/content/app/i18n/zh-TW.ts +64 -0
  49. package/corpus/templates/content/app/i18n-data.ts +784 -0
  50. package/corpus/templates/content/changelog/2026-06-29-builder-review-checks-now-preserve-publish-and-unpublish-cho.md +6 -0
  51. package/corpus/templates/content/changelog/2026-06-29-database-sources-can-now-be-added-as-more-rows-or-matched.md +6 -0
  52. package/corpus/templates/content/changelog/2026-06-29-fixed-loading-the-final-rows-in-larger-databases-without-bre.md +6 -0
  53. package/corpus/templates/content/shared/api.ts +91 -1
  54. package/corpus/templates/content/vite.config.ts +14 -7
  55. package/dist/cli/create.d.ts.map +1 -1
  56. package/dist/cli/create.js +1 -0
  57. package/dist/cli/create.js.map +1 -1
  58. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  59. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  60. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  61. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  62. package/dist/collab/routes.d.ts +2 -2
  63. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  64. package/dist/notifications/routes.d.ts +1 -1
  65. package/dist/observability/routes.d.ts +7 -7
  66. package/dist/resources/handlers.d.ts +2 -2
  67. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  68. package/dist/server/transcribe-voice.d.ts +1 -1
  69. package/dist/sharing/registry.d.ts.map +1 -1
  70. package/dist/sharing/registry.js +11 -0
  71. package/dist/sharing/registry.js.map +1 -1
  72. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  73. package/dist/templates/workspace-root/pnpm-workspace.yaml +1 -0
  74. package/package.json +1 -1
  75. package/src/templates/default/pnpm-workspace.yaml +1 -0
  76. package/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
@@ -0,0 +1,282 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { assertAccess } from "@agent-native/core/sharing";
3
+ import { z } from "zod";
4
+
5
+ import {
6
+ type BuilderSourceBatchItemResult,
7
+ type ContentDatabaseSource,
8
+ type ContentDatabaseSourceChangeSet,
9
+ type ExecuteBuilderSourceBatchRequest,
10
+ type ExecuteBuilderSourceBatchResponse,
11
+ type ExecuteBuilderSourceBatchTransition,
12
+ } from "../shared/api.js";
13
+ import {
14
+ getContentDatabaseSourceSnapshotForWrite,
15
+ resolveDatabaseForSourceMutation,
16
+ } from "./_database-source-utils.js";
17
+ import {
18
+ executeBuilderSourceExecutionWithDeps,
19
+ realExecutionDeps,
20
+ } from "./execute-builder-source-execution.js";
21
+ import prepareBuilderSourceExecution from "./prepare-builder-source-execution.js";
22
+
23
+ type DatabaseRecord = NonNullable<
24
+ Awaited<ReturnType<typeof resolveDatabaseForSourceMutation>>
25
+ >;
26
+
27
+ export const DEFAULT_BUILDER_SOURCE_BATCH_CONCURRENCY = 3;
28
+ export const MAX_BUILDER_SOURCE_BATCH_CONCURRENCY = 8;
29
+
30
+ export interface ExecuteBuilderSourceBatchDeps {
31
+ resolveDatabase: (
32
+ args: Pick<ExecuteBuilderSourceBatchRequest, "databaseId" | "documentId">,
33
+ ) => Promise<DatabaseRecord | null>;
34
+ assertEditor: (database: DatabaseRecord) => Promise<void>;
35
+ getSourceSnapshot: (
36
+ database: DatabaseRecord,
37
+ ) => Promise<ContentDatabaseSource | null>;
38
+ runOne: (
39
+ changeSetId: string,
40
+ transition?: ExecuteBuilderSourceBatchTransition,
41
+ ) => Promise<BuilderSourceBatchItemResult>;
42
+ }
43
+
44
+ function realBatchDeps(
45
+ args: Pick<
46
+ ExecuteBuilderSourceBatchRequest,
47
+ "databaseId" | "documentId" | "sourceId"
48
+ >,
49
+ ): ExecuteBuilderSourceBatchDeps {
50
+ return {
51
+ resolveDatabase: (request) => resolveDatabaseForSourceMutation(request),
52
+ assertEditor: async (database) => {
53
+ await assertAccess("document", database.documentId, "editor");
54
+ },
55
+ getSourceSnapshot: (database) =>
56
+ getContentDatabaseSourceSnapshotForWrite(database, args.sourceId),
57
+ runOne: async (changeSetId, transition) => {
58
+ const executionArgs = {
59
+ databaseId: args.databaseId,
60
+ documentId: args.documentId,
61
+ sourceId: args.sourceId,
62
+ changeSetId,
63
+ publicationTransition: transition?.publicationTransition,
64
+ confirmUnpublish: transition?.confirmUnpublish,
65
+ };
66
+ if (transition?.publicationTransition) {
67
+ await prepareBuilderSourceExecution.run(executionArgs);
68
+ }
69
+ try {
70
+ await executeBuilderSourceExecutionWithDeps(
71
+ executionArgs,
72
+ realExecutionDeps(args.sourceId),
73
+ );
74
+ } catch (error) {
75
+ if (!isMissingGateMessage(errorMessage(error))) {
76
+ throw error;
77
+ }
78
+ await prepareBuilderSourceExecution.run(executionArgs);
79
+ await executeBuilderSourceExecutionWithDeps(
80
+ executionArgs,
81
+ realExecutionDeps(args.sourceId),
82
+ );
83
+ }
84
+ return { changeSetId, status: "succeeded" };
85
+ },
86
+ };
87
+ }
88
+
89
+ function errorMessage(error: unknown) {
90
+ return error instanceof Error && error.message
91
+ ? error.message
92
+ : "Unknown Builder batch error.";
93
+ }
94
+
95
+ function isMissingGateMessage(message: string) {
96
+ return /prepare the builder execution gate/i.test(message);
97
+ }
98
+
99
+ function isBlockedMessage(message: string) {
100
+ return [
101
+ /blocked/i,
102
+ /approve/i,
103
+ /disabled/i,
104
+ /not allowed/i,
105
+ /no longer exists/i,
106
+ /changed since/i,
107
+ /already running/i,
108
+ /requires/i,
109
+ /not ready/i,
110
+ /refresh/i,
111
+ /not executable/i,
112
+ ].some((pattern) => pattern.test(message));
113
+ }
114
+
115
+ function clampConcurrency(value: number | undefined) {
116
+ if (!value || !Number.isFinite(value)) {
117
+ return DEFAULT_BUILDER_SOURCE_BATCH_CONCURRENCY;
118
+ }
119
+ return Math.max(1, Math.min(MAX_BUILDER_SOURCE_BATCH_CONCURRENCY, value));
120
+ }
121
+
122
+ function uniqueIds(ids: string[]) {
123
+ return Array.from(new Set(ids.filter((id) => id.trim())));
124
+ }
125
+
126
+ function hasSucceededExecution(changeSet: ContentDatabaseSourceChangeSet) {
127
+ return changeSet.executions.some(
128
+ (execution) => execution.state === "succeeded",
129
+ );
130
+ }
131
+
132
+ function defaultBatchChangeSetIds(source: ContentDatabaseSource) {
133
+ return source.changeSets
134
+ .filter(
135
+ (changeSet) =>
136
+ changeSet.direction === "outbound" && changeSet.state === "approved",
137
+ )
138
+ .map((changeSet) => changeSet.id);
139
+ }
140
+
141
+ function explicitBatchBlocker(
142
+ changeSet: ContentDatabaseSourceChangeSet | undefined,
143
+ ) {
144
+ if (!changeSet) return "Source change-set not found.";
145
+ if (changeSet.direction !== "outbound") {
146
+ return "Only outbound Builder change sets can be executed.";
147
+ }
148
+ if (
149
+ changeSet.state !== "pending_push" &&
150
+ changeSet.state !== "staged_revision" &&
151
+ changeSet.state !== "approved"
152
+ ) {
153
+ return "Approve the Builder change set before executing it.";
154
+ }
155
+ return null;
156
+ }
157
+
158
+ async function runBoundedPool<T>(
159
+ items: T[],
160
+ maxConcurrency: number,
161
+ worker: (item: T, index: number) => Promise<BuilderSourceBatchItemResult>,
162
+ ) {
163
+ const results = new Array<BuilderSourceBatchItemResult>(items.length);
164
+ let nextIndex = 0;
165
+ const workerCount = Math.min(maxConcurrency, items.length);
166
+ await Promise.all(
167
+ Array.from({ length: workerCount }, async () => {
168
+ while (nextIndex < items.length) {
169
+ const index = nextIndex;
170
+ nextIndex += 1;
171
+ results[index] = await worker(items[index], index);
172
+ }
173
+ }),
174
+ );
175
+ return results;
176
+ }
177
+
178
+ function summarize(results: BuilderSourceBatchItemResult[]) {
179
+ return {
180
+ total: results.length,
181
+ succeeded: results.filter((result) => result.status === "succeeded").length,
182
+ blocked: results.filter((result) => result.status === "blocked").length,
183
+ failed: results.filter((result) => result.status === "failed").length,
184
+ };
185
+ }
186
+
187
+ export async function executeBuilderSourceBatchWithDeps(
188
+ args: ExecuteBuilderSourceBatchRequest,
189
+ deps: ExecuteBuilderSourceBatchDeps,
190
+ ): Promise<ExecuteBuilderSourceBatchResponse> {
191
+ const database = await deps.resolveDatabase(args);
192
+ if (!database) throw new Error("Database not found.");
193
+ await deps.assertEditor(database);
194
+
195
+ const source = await deps.getSourceSnapshot(database);
196
+ if (!source || source.sourceType !== "builder-cms") {
197
+ throw new Error("Attach a Builder CMS source before executing writes.");
198
+ }
199
+
200
+ const ids = uniqueIds(args.changeSetIds ?? defaultBatchChangeSetIds(source));
201
+ const changeSetsById = new Map(
202
+ source.changeSets.map((changeSet) => [changeSet.id, changeSet]),
203
+ );
204
+ const maxConcurrency = clampConcurrency(args.maxConcurrency);
205
+ const results = await runBoundedPool(
206
+ ids,
207
+ maxConcurrency,
208
+ async (changeSetId) => {
209
+ const changeSet = changeSetsById.get(changeSetId);
210
+ if (changeSet && hasSucceededExecution(changeSet)) {
211
+ return {
212
+ changeSetId,
213
+ status: "succeeded",
214
+ message: "Builder execution already succeeded; skipped.",
215
+ };
216
+ }
217
+
218
+ const blocker = explicitBatchBlocker(changeSet);
219
+ if (blocker) {
220
+ return { changeSetId, status: "blocked", message: blocker };
221
+ }
222
+
223
+ try {
224
+ return await deps.runOne(changeSetId, args.transitions?.[changeSetId]);
225
+ } catch (error) {
226
+ const message = errorMessage(error);
227
+ return {
228
+ changeSetId,
229
+ status: isBlockedMessage(message) ? "blocked" : "failed",
230
+ message,
231
+ };
232
+ }
233
+ },
234
+ );
235
+
236
+ return {
237
+ summary: summarize(results),
238
+ results,
239
+ };
240
+ }
241
+
242
+ const transitionSchema = z.object({
243
+ publicationTransition: z.enum(["publish", "unpublish"]).optional(),
244
+ confirmUnpublish: z.boolean().optional(),
245
+ });
246
+
247
+ export default defineAction({
248
+ description:
249
+ "Execute a bounded batch of approved outbound Builder CMS change-sets. Each item uses the existing prepare and execute gates, continues on item errors, and does not publish or unpublish unless an explicit per-change-set transition is provided.",
250
+ schema: z.object({
251
+ databaseId: z.string().optional().describe("Database ID"),
252
+ documentId: z.string().optional().describe("Database document/page ID"),
253
+ sourceId: z
254
+ .string()
255
+ .optional()
256
+ .describe("Target source ID (defaults to the primary source)"),
257
+ changeSetIds: z
258
+ .array(z.string())
259
+ .optional()
260
+ .describe(
261
+ "Approved source change-set IDs. Defaults to all approved outbound Builder change-sets.",
262
+ ),
263
+ maxConcurrency: z
264
+ .number()
265
+ .int()
266
+ .min(1)
267
+ .max(MAX_BUILDER_SOURCE_BATCH_CONCURRENCY)
268
+ .optional()
269
+ .describe("Maximum Builder change-sets to execute at once."),
270
+ transitions: z
271
+ .record(z.string(), transitionSchema)
272
+ .optional()
273
+ .describe(
274
+ "Optional per-change-set publication transitions. Omit by default to preserve publication state.",
275
+ ),
276
+ }),
277
+ run: async (
278
+ args: ExecuteBuilderSourceBatchRequest,
279
+ ): Promise<ExecuteBuilderSourceBatchResponse> => {
280
+ return executeBuilderSourceBatchWithDeps(args, realBatchDeps(args));
281
+ },
282
+ });
@@ -13,6 +13,10 @@ import {
13
13
  type ContentDatabaseSourcePushMode,
14
14
  type ExecuteBuilderSourceExecutionRequest,
15
15
  } from "../shared/api.js";
16
+ import {
17
+ type BuilderCmsEntryLiveState,
18
+ readBuilderCmsEntryLiveState,
19
+ } from "./_builder-cms-read-client.js";
16
20
  import { builderCmsQualifiedId } from "./_builder-cms-source-adapter.js";
17
21
  import type {
18
22
  BuilderCmsExecutionPayload,
@@ -21,6 +25,7 @@ import type {
21
25
  import {
22
26
  buildBuilderCmsExecutionPlan,
23
27
  builderCmsExecutionIdempotencyKey,
28
+ resolveBuilderCmsExecutionPushMode,
24
29
  validateBuilderCmsExecutionDryRun,
25
30
  } from "./_builder-cms-write-adapter.js";
26
31
  import {
@@ -28,7 +33,7 @@ import {
28
33
  executeBuilderCmsWrite,
29
34
  } from "./_builder-cms-write-client.js";
30
35
  import {
31
- getContentDatabaseSourceSnapshot,
36
+ getContentDatabaseSourceSnapshotForWrite,
32
37
  resolveDatabaseForSourceMutation,
33
38
  } from "./_database-source-utils.js";
34
39
  import { getContentDatabaseResponse } from "./_database-utils.js";
@@ -94,6 +99,10 @@ export interface ExecuteBuilderSourceExecutionDeps {
94
99
  executeWrite: (args: {
95
100
  request: BuilderCmsExecutionPayload["request"];
96
101
  }) => ReturnType<typeof executeBuilderCmsWrite>;
102
+ readLiveEntry: (args: {
103
+ model: string;
104
+ entryId: string;
105
+ }) => Promise<BuilderCmsEntryLiveState>;
97
106
  reconcileWrite: (args: {
98
107
  database: DatabaseRecord;
99
108
  source: ContentDatabaseSource;
@@ -206,6 +215,127 @@ function proposedSourceDisplayKey(
206
215
  : fallback;
207
216
  }
208
217
 
218
+ function builderCmsResponseRecord(
219
+ value: unknown,
220
+ ): Record<string, unknown> | null {
221
+ return value && typeof value === "object" && !Array.isArray(value)
222
+ ? (value as Record<string, unknown>)
223
+ : null;
224
+ }
225
+
226
+ function builderCmsResponseValue(
227
+ value: unknown,
228
+ keys: string[],
229
+ ): number | string | null {
230
+ const record = builderCmsResponseRecord(value);
231
+ if (!record) return null;
232
+ for (const key of keys) {
233
+ const direct = record[key];
234
+ if (typeof direct === "string" && direct.trim()) return direct.trim();
235
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
236
+ }
237
+ for (const key of ["entry", "result", "content", "data"]) {
238
+ const nested = builderCmsResponseValue(record[key], keys);
239
+ if (nested !== null) return nested;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ function builderCmsAuthoritativeLastUpdated(
245
+ writeResult: BuilderCmsWriteResult,
246
+ fallback: string,
247
+ ) {
248
+ return String(
249
+ builderCmsResponseValue(writeResult.responseBody, [
250
+ "lastUpdated",
251
+ "lastUpdatedAt",
252
+ "updatedAt",
253
+ "last_source_updated_at",
254
+ ]) ?? fallback,
255
+ );
256
+ }
257
+
258
+ function sourceRowForChangeSet(
259
+ source: ContentDatabaseSource,
260
+ changeSet: ContentDatabaseSourceChangeSet,
261
+ ) {
262
+ return (
263
+ source.rows.find(
264
+ (row) =>
265
+ row.documentId === changeSet.documentId ||
266
+ row.databaseItemId === changeSet.databaseItemId,
267
+ ) ?? null
268
+ );
269
+ }
270
+
271
+ function requiresLivePreflight(effect: BuilderCmsExecutionPayload["effect"]) {
272
+ return (
273
+ effect === "update_in_place" ||
274
+ effect === "publish" ||
275
+ effect === "unpublish"
276
+ );
277
+ }
278
+
279
+ function normalizedLiveTimestamp(value: number | string | null | undefined) {
280
+ return value === null || value === undefined ? null : String(value);
281
+ }
282
+
283
+ function toEpochMs(value: number | string | null | undefined) {
284
+ if (value === null || value === undefined) return null;
285
+ if (typeof value === "number") {
286
+ return Number.isFinite(value) ? value : null;
287
+ }
288
+
289
+ const trimmed = value.trim();
290
+ if (!trimmed) return null;
291
+
292
+ const numeric = Number(trimmed);
293
+ if (Number.isFinite(numeric)) return numeric;
294
+
295
+ const parsed = Date.parse(trimmed);
296
+ return Number.isFinite(parsed) ? parsed : null;
297
+ }
298
+
299
+ function liveTimestampsDiffer(args: {
300
+ liveLastUpdated: number | string | null | undefined;
301
+ baselineLastUpdated: string | null | undefined;
302
+ }) {
303
+ const liveEpoch = toEpochMs(args.liveLastUpdated);
304
+ const baselineEpoch = toEpochMs(args.baselineLastUpdated);
305
+ if (liveEpoch !== null && baselineEpoch !== null) {
306
+ return liveEpoch !== baselineEpoch;
307
+ }
308
+ return (
309
+ normalizedLiveTimestamp(args.liveLastUpdated) !==
310
+ normalizedLiveTimestamp(args.baselineLastUpdated)
311
+ );
312
+ }
313
+
314
+ function livePreflightBlockMessage(args: {
315
+ liveState: BuilderCmsEntryLiveState;
316
+ baselineLastUpdated: string | null;
317
+ effect: BuilderCmsExecutionPayload["effect"];
318
+ }) {
319
+ if (!args.liveState.exists) {
320
+ return "Builder entry no longer exists; refresh the source.";
321
+ }
322
+ if (
323
+ liveTimestampsDiffer({
324
+ liveLastUpdated: args.liveState.lastUpdated,
325
+ baselineLastUpdated: args.baselineLastUpdated,
326
+ })
327
+ ) {
328
+ return "Builder entry changed since this diff was approved; refresh and re-review.";
329
+ }
330
+ if (args.effect === "publish" && args.liveState.published !== "draft") {
331
+ return "Entry is already published.";
332
+ }
333
+ if (args.effect === "unpublish" && args.liveState.published !== "published") {
334
+ return "Entry is not currently published.";
335
+ }
336
+ return null;
337
+ }
338
+
209
339
  export function builderCmsReconciledSourceRowPatch(args: {
210
340
  source: ContentDatabaseSource;
211
341
  changeSet: ContentDatabaseSourceChangeSet;
@@ -222,6 +352,10 @@ export function builderCmsReconciledSourceRowPatch(args: {
222
352
  const entryId =
223
353
  args.writeResult.entryId ?? args.plan.payload.target.entryId ?? null;
224
354
  if (!entryId) return null;
355
+ const sourceUpdatedAt = builderCmsAuthoritativeLastUpdated(
356
+ args.writeResult,
357
+ args.now,
358
+ );
225
359
 
226
360
  return {
227
361
  sourceRowId: entryId,
@@ -237,7 +371,7 @@ export function builderCmsReconciledSourceRowPatch(args: {
237
371
  syncState: "idle",
238
372
  freshness: "fresh",
239
373
  lastSyncedAt: args.now,
240
- lastSourceUpdatedAt: args.now,
374
+ lastSourceUpdatedAt: sourceUpdatedAt,
241
375
  updatedAt: args.now,
242
376
  };
243
377
  }
@@ -316,21 +450,24 @@ async function reconcileBuilderCmsWrite(args: {
316
450
  syncState: "idle",
317
451
  freshness: "fresh",
318
452
  lastRefreshedAt: args.now,
319
- lastSourceUpdatedAt: args.now,
453
+ lastSourceUpdatedAt: patch.lastSourceUpdatedAt,
320
454
  lastError: null,
321
455
  updatedAt: args.now,
322
456
  })
323
457
  .where(eq(schema.contentDatabaseSources.id, args.source.id));
324
458
  }
325
459
 
326
- function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps {
460
+ export function realExecutionDeps(
461
+ sourceId?: string,
462
+ ): ExecuteBuilderSourceExecutionDeps {
327
463
  return {
328
464
  now: () => new Date().toISOString(),
329
465
  resolveDatabase: (args) => resolveDatabaseForSourceMutation(args),
330
466
  assertEditor: async (database) => {
331
467
  await assertAccess("document", database.documentId, "editor");
332
468
  },
333
- getSourceSnapshot: (database) => getContentDatabaseSourceSnapshot(database),
469
+ getSourceSnapshot: (database) =>
470
+ getContentDatabaseSourceSnapshotForWrite(database, sourceId),
334
471
  getExecution: async (args) => {
335
472
  const [execution] = await getDb()
336
473
  .select()
@@ -426,6 +563,7 @@ function realExecutionDeps(): ExecuteBuilderSourceExecutionDeps {
426
563
  .where(eq(schema.contentDatabaseSourceExecutions.id, args.executionId));
427
564
  },
428
565
  executeWrite: (args) => executeBuilderCmsWrite(args),
566
+ readLiveEntry: (args) => readBuilderCmsEntryLiveState(args),
429
567
  reconcileWrite: reconcileBuilderCmsWrite,
430
568
  getResponse: (databaseId) => getContentDatabaseResponse(databaseId),
431
569
  };
@@ -452,18 +590,28 @@ export async function executeBuilderSourceExecutionWithDeps(
452
590
  throw new Error("Only outbound Builder change sets can be executed.");
453
591
  }
454
592
 
593
+ const resolvedPushMode = resolveBuilderCmsExecutionPushMode({
594
+ source,
595
+ changeSet,
596
+ });
597
+ const effectivePushMode =
598
+ resolvedPushMode === "none" ? "autosave" : resolvedPushMode;
455
599
  const pushMode = executablePushMode(
456
- args.pushModeConfirmation ?? changeSet.pushMode ?? source.metadata.pushMode,
600
+ args.pushModeConfirmation ?? effectivePushMode,
457
601
  );
458
602
  if (!pushMode) {
459
603
  throw new Error(
460
604
  "Builder execution requires Autosave, Draft, or Publish push mode.",
461
605
  );
462
606
  }
607
+ // The gate key is keyed on the RAW resolved push mode (matching the plan in
608
+ // buildBuilderCmsExecutionPlan) — NOT on pushModeConfirmation. Keying on the
609
+ // confirmation would let a caller's confirmation diverge the key from the
610
+ // prepared gate; the confirmation is still validated inside the plan below.
463
611
  const expectedKey = builderCmsExecutionIdempotencyKey({
464
612
  sourceId: source.id,
465
613
  changeSetId: changeSet.id,
466
- pushMode,
614
+ pushMode: resolvedPushMode,
467
615
  });
468
616
  if (args.idempotencyKey && args.idempotencyKey !== expectedKey) {
469
617
  throw new Error(
@@ -495,6 +643,8 @@ export async function executeBuilderSourceExecutionWithDeps(
495
643
  source,
496
644
  changeSet,
497
645
  pushModeConfirmation: pushMode,
646
+ publicationTransition: args.publicationTransition,
647
+ confirmUnpublish: args.confirmUnpublish,
498
648
  });
499
649
  const storedPayload = parsePayload(execution.payloadJson);
500
650
  const validatedPayload = validateBuilderCmsExecutionDryRun({
@@ -590,6 +740,53 @@ export async function executeBuilderSourceExecutionWithDeps(
590
740
  return deps.getResponse(database.id);
591
741
  }
592
742
 
743
+ if (requiresLivePreflight(plan.payload.effect)) {
744
+ const entryId = plan.payload.target.entryId;
745
+ if (!entryId) {
746
+ const message = "Builder entry no longer exists; refresh the source.";
747
+ await deps.updateExecutionState({
748
+ executionId: execution.id,
749
+ state: "blocked",
750
+ summary: `${plan.summary} Execution blocked before write.`,
751
+ payload: validatedPayload,
752
+ lastError: message,
753
+ now,
754
+ });
755
+ throw new Error(message);
756
+ }
757
+
758
+ const liveState = await deps.readLiveEntry({
759
+ model: plan.payload.target.model,
760
+ entryId,
761
+ });
762
+ const targetRow = sourceRowForChangeSet(source, changeSet);
763
+ const message = livePreflightBlockMessage({
764
+ liveState,
765
+ baselineLastUpdated: targetRow?.lastSourceUpdatedAt ?? null,
766
+ effect: plan.payload.effect,
767
+ });
768
+ if (message) {
769
+ await deps.updateExecutionState({
770
+ executionId: execution.id,
771
+ state: "blocked",
772
+ summary: `${plan.summary} Execution blocked before write.`,
773
+ payload: {
774
+ ...validatedPayload,
775
+ livePreflight: {
776
+ checkedAt: now,
777
+ exists: liveState.exists,
778
+ published: liveState.published,
779
+ lastUpdated: liveState.lastUpdated,
780
+ id: liveState.id,
781
+ },
782
+ },
783
+ lastError: message,
784
+ now,
785
+ });
786
+ throw new Error(message);
787
+ }
788
+ }
789
+
593
790
  const claimed = await deps.claimExecution({
594
791
  executionId: execution.id,
595
792
  summary: `Running Builder ${plan.pushMode} execution.`,
@@ -661,6 +858,10 @@ export default defineAction({
661
858
  schema: z.object({
662
859
  databaseId: z.string().optional().describe("Database ID"),
663
860
  documentId: z.string().optional().describe("Database document/page ID"),
861
+ sourceId: z
862
+ .string()
863
+ .optional()
864
+ .describe("Target source ID (defaults to the primary source)"),
664
865
  changeSetId: z.string().describe("Approved source change-set ID"),
665
866
  idempotencyKey: z
666
867
  .string()
@@ -670,10 +871,21 @@ export default defineAction({
670
871
  .enum(["autosave", "draft", "publish"])
671
872
  .optional()
672
873
  .describe("Explicit push mode confirmation for the live write"),
874
+ publicationTransition: z
875
+ .enum(["publish", "unpublish"])
876
+ .optional()
877
+ .describe("Explicit publication transition to validate at write time"),
878
+ confirmUnpublish: z
879
+ .boolean()
880
+ .optional()
881
+ .describe("Required explicit confirmation for unpublish transitions"),
673
882
  }),
674
883
  run: async (
675
884
  args: ExecuteBuilderSourceExecutionRequest,
676
885
  ): Promise<ContentDatabaseResponse> => {
677
- return executeBuilderSourceExecutionWithDeps(args, realExecutionDeps());
886
+ return executeBuilderSourceExecutionWithDeps(
887
+ args,
888
+ realExecutionDeps(args.sourceId),
889
+ );
678
890
  },
679
891
  });
@@ -8,7 +8,10 @@ import type {
8
8
  ContentDatabaseResponse,
9
9
  ContentDatabaseUnavailableResponse,
10
10
  } from "../shared/api.js";
11
- import { getContentDatabaseResponse } from "./_database-utils.js";
11
+ import {
12
+ CONTENT_DATABASE_MAX_READ_LIMIT,
13
+ getContentDatabaseResponse,
14
+ } from "./_database-utils.js";
12
15
 
13
16
  export default defineAction({
14
17
  description:
@@ -16,7 +19,12 @@ export default defineAction({
16
19
  schema: z.object({
17
20
  databaseId: z.string().optional().describe("Database ID"),
18
21
  documentId: z.string().optional().describe("Database document/page ID"),
19
- limit: z.coerce.number().int().min(1).max(500).optional(),
22
+ limit: z.coerce
23
+ .number()
24
+ .int()
25
+ .min(1)
26
+ .max(CONTENT_DATABASE_MAX_READ_LIMIT)
27
+ .optional(),
20
28
  offset: z.coerce.number().int().min(0).optional(),
21
29
  }),
22
30
  http: { method: "GET" },
@@ -10,7 +10,7 @@ import type {
10
10
  } from "../shared/api.js";
11
11
  import { buildBuilderCmsExecutionPlan } from "./_builder-cms-write-adapter.js";
12
12
  import {
13
- getContentDatabaseSourceSnapshot,
13
+ getContentDatabaseSourceSnapshotForWrite,
14
14
  resolveDatabaseForSourceMutation,
15
15
  } from "./_database-source-utils.js";
16
16
  import { getContentDatabaseResponse } from "./_database-utils.js";
@@ -21,11 +21,23 @@ export default defineAction({
21
21
  schema: z.object({
22
22
  databaseId: z.string().optional().describe("Database ID"),
23
23
  documentId: z.string().optional().describe("Database document/page ID"),
24
+ sourceId: z
25
+ .string()
26
+ .optional()
27
+ .describe("Target source ID (defaults to the primary source)"),
24
28
  changeSetId: z.string().describe("Approved source change-set ID"),
25
29
  pushModeConfirmation: z
26
30
  .enum(["autosave", "draft", "publish"])
27
31
  .optional()
28
32
  .describe("Explicit push mode confirmation for the planned write"),
33
+ publicationTransition: z
34
+ .enum(["publish", "unpublish"])
35
+ .optional()
36
+ .describe("Explicit publication transition to validate at write time"),
37
+ confirmUnpublish: z
38
+ .boolean()
39
+ .optional()
40
+ .describe("Required explicit confirmation for unpublish transitions"),
29
41
  }),
30
42
  run: async (
31
43
  args: PrepareBuilderSourceExecutionRequest,
@@ -34,7 +46,10 @@ export default defineAction({
34
46
  if (!database) throw new Error("Database not found.");
35
47
  await assertAccess("document", database.documentId, "editor");
36
48
 
37
- const source = await getContentDatabaseSourceSnapshot(database);
49
+ const source = await getContentDatabaseSourceSnapshotForWrite(
50
+ database,
51
+ args.sourceId,
52
+ );
38
53
  if (!source || source.sourceType !== "builder-cms") {
39
54
  throw new Error(
40
55
  "Attach a Builder CMS source before preparing execution.",
@@ -50,6 +65,8 @@ export default defineAction({
50
65
  source,
51
66
  changeSet,
52
67
  pushModeConfirmation: args.pushModeConfirmation,
68
+ publicationTransition: args.publicationTransition,
69
+ confirmUnpublish: args.confirmUnpublish,
53
70
  });
54
71
  const now = new Date().toISOString();
55
72
  const db = getDb();