@agent-native/core 0.80.5 → 0.80.6

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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/create.ts +1 -0
  5. package/corpus/core/src/sharing/registry.ts +17 -0
  6. package/corpus/core/src/templates/default/pnpm-workspace.yaml +1 -0
  7. package/corpus/core/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
  8. package/corpus/templates/content/AGENTS.md +45 -44
  9. package/corpus/templates/content/README.md +16 -0
  10. package/corpus/templates/content/actions/_builder-cms-read-client.ts +125 -0
  11. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +28 -6
  12. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +238 -69
  13. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +125 -43
  14. package/corpus/templates/content/actions/_database-source-utils.ts +746 -97
  15. package/corpus/templates/content/actions/_database-utils.ts +17 -14
  16. package/corpus/templates/content/actions/_federation-join.ts +14 -2
  17. package/corpus/templates/content/actions/_join-suggestion.ts +89 -14
  18. package/corpus/templates/content/actions/_local-file-documents.ts +2 -1
  19. package/corpus/templates/content/actions/attach-content-database-source.ts +124 -5
  20. package/corpus/templates/content/actions/bind-content-database-source-field.ts +256 -0
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +420 -0
  22. package/corpus/templates/content/actions/create-document.ts +8 -0
  23. package/corpus/templates/content/actions/execute-builder-source-batch.ts +282 -0
  24. package/corpus/templates/content/actions/execute-builder-source-execution.ts +220 -8
  25. package/corpus/templates/content/actions/get-content-database.ts +10 -2
  26. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +19 -2
  27. package/corpus/templates/content/actions/prepare-builder-source-review.ts +47 -15
  28. package/corpus/templates/content/actions/refresh-content-database-source.ts +11 -4
  29. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +6 -2
  30. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +32 -12
  31. package/corpus/templates/content/actions/stage-builder-revision.ts +14 -10
  32. package/corpus/templates/content/actions/validate-builder-source-execution.ts +25 -3
  33. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1520 -553
  34. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +150 -1
  35. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +647 -66
  36. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +543 -204
  37. package/corpus/templates/content/app/hooks/use-content-database.ts +62 -0
  38. package/corpus/templates/content/app/i18n/zh-TW.ts +64 -0
  39. package/corpus/templates/content/app/i18n-data.ts +784 -0
  40. package/corpus/templates/content/changelog/2026-06-29-builder-review-checks-now-preserve-publish-and-unpublish-cho.md +6 -0
  41. package/corpus/templates/content/changelog/2026-06-29-database-sources-can-now-be-added-as-more-rows-or-matched.md +6 -0
  42. package/corpus/templates/content/changelog/2026-06-29-fixed-loading-the-final-rows-in-larger-databases-without-bre.md +6 -0
  43. package/corpus/templates/content/shared/api.ts +91 -1
  44. package/corpus/templates/content/vite.config.ts +14 -7
  45. package/dist/cli/create.d.ts.map +1 -1
  46. package/dist/cli/create.js +1 -0
  47. package/dist/cli/create.js.map +1 -1
  48. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  49. package/dist/notifications/routes.d.ts +1 -1
  50. package/dist/observability/routes.d.ts +5 -5
  51. package/dist/sharing/registry.d.ts.map +1 -1
  52. package/dist/sharing/registry.js +11 -0
  53. package/dist/sharing/registry.js.map +1 -1
  54. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  55. package/dist/templates/workspace-root/pnpm-workspace.yaml +1 -0
  56. package/package.json +1 -1
  57. package/src/templates/default/pnpm-workspace.yaml +1 -0
  58. package/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
@@ -19,6 +19,8 @@ import {
19
19
  serializeDatabase,
20
20
  } from "./_property-utils.js";
21
21
 
22
+ export const CONTENT_DATABASE_MAX_READ_LIMIT = 5_000;
23
+
22
24
  function canManageRole(role: string) {
23
25
  return role === "owner" || role === "admin";
24
26
  }
@@ -78,7 +80,10 @@ export function normalizeContentDatabasePageOptions(options: {
78
80
  }) {
79
81
  const limit =
80
82
  typeof options.limit === "number" && Number.isFinite(options.limit)
81
- ? Math.max(1, Math.min(Math.floor(options.limit), 500))
83
+ ? Math.max(
84
+ 1,
85
+ Math.min(Math.floor(options.limit), CONTENT_DATABASE_MAX_READ_LIMIT),
86
+ )
82
87
  : null;
83
88
  const offset =
84
89
  typeof options.offset === "number" && Number.isFinite(options.offset)
@@ -181,21 +186,19 @@ export async function getContentDatabaseResponse(
181
186
  const serializedDocumentIds = new Set(
182
187
  serializedItems.map((item) => item.document.id),
183
188
  );
184
- // When paginating, the *primary* source's rows are document-backed, so we can
185
- // scope them to the visible page. Secondary rows join by canonical key (no
186
- // document), so they're left intact only matched ones overlay anyway.
189
+ // When paginating, scope every DOCUMENT-BACKED source's rows to the visible
190
+ // page that's the primary AND any row-union secondary (each row maps to a
191
+ // real document). Federated join rows carry no document (empty documentId),
192
+ // so they're kept intact — only matched ones overlay anyway.
187
193
  const pagedSources =
188
194
  limit !== null
189
- ? sources.map((source, index) =>
190
- index === 0
191
- ? {
192
- ...source,
193
- rows: source.rows.filter((row) =>
194
- serializedDocumentIds.has(row.documentId),
195
- ),
196
- }
197
- : source,
198
- )
195
+ ? sources.map((source) => ({
196
+ ...source,
197
+ rows: source.rows.filter(
198
+ (row) =>
199
+ !row.documentId || serializedDocumentIds.has(row.documentId),
200
+ ),
201
+ }))
199
202
  : sources;
200
203
  const pagedPrimary = pagedSources[0] ?? null;
201
204
 
@@ -53,6 +53,13 @@ export function federateSources(args: {
53
53
  const primaryRowByDocumentId = new Map(
54
54
  (primary?.rows ?? []).map((row) => [row.documentId, row]),
55
55
  );
56
+ const rowByDocumentId = new Map<string, ContentDatabaseSourceRow>();
57
+ for (const source of sources) {
58
+ for (const row of source.rows) {
59
+ if (!row.documentId || rowByDocumentId.has(row.documentId)) continue;
60
+ rowByDocumentId.set(row.documentId, row);
61
+ }
62
+ }
56
63
 
57
64
  // Secondary sources carrying an identity-join config.
58
65
  const secondaries = sources
@@ -82,7 +89,9 @@ export function federateSources(args: {
82
89
  return items.map((item) => ({
83
90
  ...item,
84
91
  sourceRecord:
85
- primaryRowByDocumentId.get(item.document.id) ?? item.sourceRecord,
92
+ primaryRowByDocumentId.get(item.document.id) ??
93
+ rowByDocumentId.get(item.document.id) ??
94
+ item.sourceRecord,
86
95
  }));
87
96
  }
88
97
 
@@ -112,7 +121,10 @@ export function federateSources(args: {
112
121
 
113
122
  return {
114
123
  ...item,
115
- sourceRecord: primaryRow ?? item.sourceRecord,
124
+ sourceRecord:
125
+ primaryRow ??
126
+ rowByDocumentId.get(item.document.id) ??
127
+ item.sourceRecord,
116
128
  canonicalKey,
117
129
  sourceOverlays: overlays.length > 0 ? overlays : undefined,
118
130
  };
@@ -36,7 +36,16 @@ export interface JoinSuggestion {
36
36
 
37
37
  type ValueRow = Record<string, DocumentPropertyValue>;
38
38
 
39
- const KEY_NAME_HINTS = ["url", "slug", "handle", "id", "key", "path", "ref"];
39
+ const KEY_NAME_HINTS = [
40
+ "url",
41
+ "slug",
42
+ "handle",
43
+ "id",
44
+ "key",
45
+ "path",
46
+ "ref",
47
+ "author",
48
+ ];
40
49
 
41
50
  function stringValue(value: DocumentPropertyValue): string | null {
42
51
  if (typeof value === "string") return value.trim() ? value : null;
@@ -59,6 +68,18 @@ function proposeFormula(field: string, urlLike: boolean): string {
59
68
  : `lower(trim({${field}}))`;
60
69
  }
61
70
 
71
+ function looksLikeBuilderReference(values: string[]): boolean {
72
+ return values.some((value) => /^[a-z0-9_-]+:[a-z0-9_-]+$/i.test(value));
73
+ }
74
+
75
+ function proposeFormulas(field: string, values: string[], urlLike: boolean) {
76
+ const formulas = [proposeFormula(field, urlLike)];
77
+ if (looksLikeBuilderReference(values)) {
78
+ formulas.push(`lower(trim(regexreplace({${field}}, "^[^:]+:", "")))`);
79
+ }
80
+ return formulas;
81
+ }
82
+
62
83
  function fieldLabel(field: string): string {
63
84
  return (
64
85
  field
@@ -98,12 +119,11 @@ function rawStringValuesForField(field: string, rows: ValueRow[]) {
98
119
  return raws;
99
120
  }
100
121
 
101
- function profileField(
122
+ function profileFieldWithFormula(
102
123
  field: string,
103
124
  raws: string[],
104
- urlLike: boolean,
125
+ formula: string,
105
126
  ): FieldProfile {
106
- const formula = proposeFormula(field, urlLike);
107
127
  const normalizedByRaw = new Map<string, string>();
108
128
  const normalizedSet = new Set<string>();
109
129
  for (const raw of raws) {
@@ -123,6 +143,19 @@ function jaccard(a: Set<string>, b: Set<string>): number {
123
143
  return union === 0 ? 0 : intersection / union;
124
144
  }
125
145
 
146
+ function intersectionSize(a: Set<string>, b: Set<string>): number {
147
+ let intersection = 0;
148
+ for (const value of a) if (b.has(value)) intersection += 1;
149
+ return intersection;
150
+ }
151
+
152
+ function lowInformationField(field: string): boolean {
153
+ const normalized = field.toLowerCase();
154
+ return /(^|[._-])(published|state|status|sync_state|created|updated|lastupdated|last_updated)([._-]|$)/.test(
155
+ normalized,
156
+ );
157
+ }
158
+
126
159
  function nameNudge(primaryField: string, secondaryField: string): number {
127
160
  const hinted = (field: string) =>
128
161
  KEY_NAME_HINTS.some((hint) => field.toLowerCase().includes(hint));
@@ -164,17 +197,59 @@ export function suggestJoinKey(args: {
164
197
  for (const [secondaryField, secondaryRaws] of secondaryRawByField) {
165
198
  const pairUrlLike =
166
199
  looksLikeUrl(primaryRaws) || looksLikeUrl(secondaryRaws);
167
- const primary = profileField(primaryField, primaryRaws, pairUrlLike);
168
- const secondary = profileField(
169
- secondaryField,
170
- secondaryRaws,
200
+ for (const primaryFormula of proposeFormulas(
201
+ primaryField,
202
+ primaryRaws,
171
203
  pairUrlLike,
172
- );
173
- const overlap = jaccard(primary.normalizedSet, secondary.normalizedSet);
174
- if (overlap <= 0) continue;
175
- const score = overlap + nameNudge(primary.field, secondary.field);
176
- if (!best || score > best.score) {
177
- best = { primary, secondary, score, overlap };
204
+ )) {
205
+ for (const secondaryFormula of proposeFormulas(
206
+ secondaryField,
207
+ secondaryRaws,
208
+ pairUrlLike,
209
+ )) {
210
+ const primary = profileFieldWithFormula(
211
+ primaryField,
212
+ primaryRaws,
213
+ primaryFormula,
214
+ );
215
+ const secondary = profileFieldWithFormula(
216
+ secondaryField,
217
+ secondaryRaws,
218
+ secondaryFormula,
219
+ );
220
+ const overlap = jaccard(
221
+ primary.normalizedSet,
222
+ secondary.normalizedSet,
223
+ );
224
+ if (overlap <= 0) continue;
225
+ const sharedCount = intersectionSize(
226
+ primary.normalizedSet,
227
+ secondary.normalizedSet,
228
+ );
229
+ if (
230
+ sharedCount < 2 &&
231
+ (primary.normalizedByRaw.size > 2 ||
232
+ secondary.normalizedByRaw.size > 2)
233
+ ) {
234
+ continue;
235
+ }
236
+ const lowInfoPenalty =
237
+ lowInformationField(primary.field) ||
238
+ lowInformationField(secondary.field)
239
+ ? 0.5
240
+ : 0;
241
+ const cardinalityNudge =
242
+ Math.min(primary.normalizedSet.size, secondary.normalizedSet.size) *
243
+ 0.001;
244
+ const score =
245
+ overlap +
246
+ nameNudge(primary.field, secondary.field) +
247
+ cardinalityNudge -
248
+ lowInfoPenalty;
249
+ if (!best || score > best.score) {
250
+ best = { primary, secondary, score, overlap };
251
+ }
252
+ }
178
253
  }
179
254
  }
180
255
  }
@@ -309,7 +309,8 @@ function upsertFrontmatter(
309
309
  return body;
310
310
  }
311
311
 
312
- return `---\n${nextLines.filter(Boolean).join("\n")}\n---\n\n${body}`;
312
+ const frontmatterBlock = `---\n${nextLines.filter(Boolean).join("\n")}\n---\n`;
313
+ return body ? `${frontmatterBlock}\n${body}` : frontmatterBlock;
313
314
  }
314
315
 
315
316
  function stripDuplicateTitleHeading(content: string, title: string) {
@@ -15,6 +15,8 @@ import {
15
15
  } from "./_builder-cms-read-client.js";
16
16
  import type { BuilderCmsSourceEntry } from "./_builder-cms-source-adapter.js";
17
17
  import {
18
+ databaseSourceExistsForTable,
19
+ ensureDatabaseSourceProperty,
18
20
  getExistingSource,
19
21
  getSourceRows,
20
22
  importBuilderCmsEntriesAsDatabaseItems,
@@ -129,10 +131,22 @@ export default defineAction({
129
131
  .string()
130
132
  .optional()
131
133
  .describe("Source table/model name, for example content_items."),
134
+ relationshipMode: z
135
+ .enum(["items", "details"])
136
+ .optional()
137
+ .describe(
138
+ "How to attach a second source: items adds more rows; details joins fields onto existing rows.",
139
+ ),
132
140
  join: joinSchema
133
141
  .optional()
134
142
  .describe(
135
- "When adding a SECOND source, the canonical-key join that federates it onto the primary (read-only overlay).",
143
+ "When relationshipMode is details, the canonical-key join that adds fields onto the primary rows.",
144
+ ),
145
+ mode: z
146
+ .enum(["replace", "add"])
147
+ .optional()
148
+ .describe(
149
+ "Backward-compatible alias: add means relationshipMode items; replace (default) re-links the primary source.",
136
150
  ),
137
151
  limit: z.coerce.number().int().min(1).max(500).default(100),
138
152
  offset: z.coerce.number().int().min(0).default(0),
@@ -160,10 +174,16 @@ export default defineAction({
160
174
  await resolveReadableLocalTableSource(sourceTable);
161
175
  }
162
176
 
163
- // Adding a SECOND source: federate it onto the primary on the canonical key
164
- // instead of replacing the binding. Read-only overlay — the secondary's
165
- // entries are NOT imported as local documents/items.
166
- if (args.join && existingSource) {
177
+ const relationshipMode =
178
+ args.relationshipMode ?? (args.mode === "add" ? "items" : undefined);
179
+
180
+ // Adding a SECOND source as details: relate it onto the primary on the
181
+ // canonical key. Read-only overlay — the secondary's entries are NOT
182
+ // imported as local documents/items.
183
+ if ((relationshipMode === "details" || args.join) && existingSource) {
184
+ if (!args.join) {
185
+ throw new Error("Choose a match key before adding source details.");
186
+ }
167
187
  let entries: BuilderCmsSourceEntry[];
168
188
  let modelFields: BuilderCmsModelFieldSummary[];
169
189
  if (sourceType === "builder-cms") {
@@ -229,6 +249,105 @@ export default defineAction({
229
249
  });
230
250
  }
231
251
 
252
+ // Adding an ADDITIONAL writable Builder source (row-union): insert a new
253
+ // source and import its entries as their OWN rows, instead of replacing the
254
+ // primary. No canonical-key join — each row belongs to exactly one source.
255
+ if (
256
+ relationshipMode === "items" &&
257
+ existingSource &&
258
+ sourceType === "builder-cms"
259
+ ) {
260
+ // Don't add the same collection twice — each "add" starts a fresh source
261
+ // with no prior rows, so a duplicate attach would re-import duplicate rows.
262
+ if (await databaseSourceExistsForTable(database.id, sourceTable)) {
263
+ throw new Error(`"${sourceTable}" is already attached as a source.`);
264
+ }
265
+ const additionalRead = await readBuilderCmsContentEntries({
266
+ model: sourceTable,
267
+ });
268
+ const additionalModelFields = await readBuilderCmsModelFields({
269
+ model: sourceTable,
270
+ });
271
+ const additionalSourceId = await insertSecondarySource({
272
+ database,
273
+ sourceType,
274
+ sourceName,
275
+ sourceTable,
276
+ now,
277
+ });
278
+ // Snapshot existing items BEFORE importing so we can bind the new source
279
+ // to ONLY the rows it imports — never the primary's existing rows.
280
+ const beforeSetup = await sourceSetupPayload(database.id);
281
+ const priorDocumentIds = new Set(
282
+ beforeSetup.response.items.map((item) => item.document.id),
283
+ );
284
+ if (additionalRead.state === "live") {
285
+ await importBuilderCmsEntriesAsDatabaseItems({
286
+ database,
287
+ entries: additionalRead.entries,
288
+ now,
289
+ sourceTable,
290
+ existingSourceRows: [],
291
+ skipTitleDedup: true,
292
+ });
293
+ }
294
+ const additionalSetup = await sourceSetupPayload(database.id);
295
+ // Only the items this collection just created — exclude the primary's.
296
+ const importedItems = additionalSetup.response.items.filter(
297
+ (item) => !priorDocumentIds.has(item.document.id),
298
+ );
299
+ const additionalEntriesByDocumentId =
300
+ additionalRead.state === "live"
301
+ ? mapBuilderCmsEntriesToLocalItems({
302
+ entries: additionalRead.entries,
303
+ items: importedItems,
304
+ sourceTable,
305
+ now,
306
+ existingRows: [],
307
+ })
308
+ : undefined;
309
+ await seedMockSourceFields({
310
+ sourceId: additionalSourceId,
311
+ ownerEmail: database.ownerEmail,
312
+ sourceType,
313
+ properties: additionalSetup.properties,
314
+ builderModelFields: additionalModelFields,
315
+ builderSampleEntries:
316
+ additionalRead.state === "live" ? additionalRead.entries : [],
317
+ now,
318
+ });
319
+ await seedMockSourceRows({
320
+ sourceId: additionalSourceId,
321
+ ownerEmail: database.ownerEmail,
322
+ sourceType,
323
+ sourceTable,
324
+ items: importedItems,
325
+ now,
326
+ builderEntriesByDocumentId: additionalEntriesByDocumentId,
327
+ });
328
+ await updateBuilderCmsSourceReadMetadata({
329
+ sourceId: additionalSourceId,
330
+ sourceTable,
331
+ readState: additionalRead.state,
332
+ entryCount: additionalRead.entries.length,
333
+ matchedRowCount: additionalEntriesByDocumentId?.size ?? 0,
334
+ fetchedAt: additionalRead.fetchedAt,
335
+ now,
336
+ message: additionalRead.message,
337
+ syncState: "linked",
338
+ });
339
+ await ensureDatabaseSourceProperty({ database, now });
340
+
341
+ return getContentDatabaseResponse(database.id, {
342
+ limit: args.limit,
343
+ offset: args.offset,
344
+ });
345
+ }
346
+
347
+ if (relationshipMode === "items" && existingSource) {
348
+ throw new Error("Only Builder sources can add more items right now.");
349
+ }
350
+
232
351
  const existingSourceRows = existingSource
233
352
  ? await getSourceRows(existingSource.id)
234
353
  : [];
@@ -0,0 +1,256 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { assertAccess } from "@agent-native/core/sharing";
3
+ import { and, eq, inArray, ne } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import type {
8
+ BindContentDatabaseSourceFieldRequest,
9
+ ContentDatabaseResponse,
10
+ } from "../shared/api.js";
11
+ import { serializePropertyValue } from "../shared/properties.js";
12
+ import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js";
13
+ import { getContentDatabaseResponse } from "./_database-utils.js";
14
+ import { nanoid } from "./_property-utils.js";
15
+ import {
16
+ propertyTypeForSourceField,
17
+ sourceFieldPropertyValuesFromRows,
18
+ } from "./add-content-database-source-field-property.js";
19
+
20
+ const SOURCE_TAG_PROPERTY_NAME = "Source";
21
+
22
+ export default defineAction({
23
+ description:
24
+ "Bind a source field to an existing database column (row-union per-source field binding), or unbind it. Binding routes the source's per-row values into the shared column; types must be compatible. Pass propertyId: null to unbind.",
25
+ schema: z.object({
26
+ databaseId: z.string().optional().describe("Database ID"),
27
+ documentId: z.string().optional().describe("Database document/page ID"),
28
+ sourceFieldId: z.string().describe("Source field mapping ID"),
29
+ propertyId: z
30
+ .string()
31
+ .nullable()
32
+ .describe(
33
+ "Target column property to bind the field to, or null to unbind.",
34
+ ),
35
+ }),
36
+ run: async (
37
+ args: BindContentDatabaseSourceFieldRequest,
38
+ ): Promise<ContentDatabaseResponse> => {
39
+ const database = await resolveDatabaseForSourceMutation(args);
40
+ if (!database) throw new Error("Database not found.");
41
+ await assertAccess("document", database.documentId, "editor");
42
+
43
+ const db = getDb();
44
+ const [field] = await db
45
+ .select()
46
+ .from(schema.contentDatabaseSourceFields)
47
+ .where(eq(schema.contentDatabaseSourceFields.id, args.sourceFieldId));
48
+ if (!field) throw new Error("Source field not found.");
49
+
50
+ const [source] = await db
51
+ .select()
52
+ .from(schema.contentDatabaseSources)
53
+ .where(
54
+ and(
55
+ eq(schema.contentDatabaseSources.id, field.sourceId),
56
+ eq(schema.contentDatabaseSources.databaseId, database.id),
57
+ ),
58
+ );
59
+ if (!source) {
60
+ throw new Error("Source field does not belong to this database.");
61
+ }
62
+ if (field.mappingType === "title") {
63
+ throw new Error("The title field is bound to Name and can't be rebound.");
64
+ }
65
+ if (field.mappingType === "system" || field.writeOwner === "derived") {
66
+ throw new Error("Integration-managed fields can't be bound to a column.");
67
+ }
68
+
69
+ const now = new Date().toISOString();
70
+
71
+ // ── Unbind ────────────────────────────────────────────────────────────
72
+ if (args.propertyId === null) {
73
+ if (field.propertyId) {
74
+ const sourceRows = await db
75
+ .select({ documentId: schema.contentDatabaseSourceRows.documentId })
76
+ .from(schema.contentDatabaseSourceRows)
77
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
78
+ const sourceDocumentIds = sourceRows
79
+ .map((row) => row.documentId)
80
+ .filter((id): id is string => Boolean(id));
81
+ if (sourceDocumentIds.length > 0) {
82
+ await db
83
+ .delete(schema.documentPropertyValues)
84
+ .where(
85
+ and(
86
+ eq(schema.documentPropertyValues.propertyId, field.propertyId),
87
+ inArray(
88
+ schema.documentPropertyValues.documentId,
89
+ sourceDocumentIds,
90
+ ),
91
+ ),
92
+ );
93
+ }
94
+ }
95
+ await db
96
+ .update(schema.contentDatabaseSourceFields)
97
+ .set({
98
+ propertyId: null,
99
+ localFieldKey: field.sourceFieldKey,
100
+ updatedAt: now,
101
+ })
102
+ .where(eq(schema.contentDatabaseSourceFields.id, field.id));
103
+ await db
104
+ .update(schema.contentDatabaseSources)
105
+ .set({ updatedAt: now })
106
+ .where(eq(schema.contentDatabaseSources.id, source.id));
107
+ return getContentDatabaseResponse(database.id);
108
+ }
109
+
110
+ // ── Bind to an existing column ─────────────────────────────────────────
111
+ const [property] = await db
112
+ .select()
113
+ .from(schema.documentPropertyDefinitions)
114
+ .where(
115
+ and(
116
+ eq(schema.documentPropertyDefinitions.id, args.propertyId),
117
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
118
+ ),
119
+ );
120
+ if (!property) {
121
+ throw new Error("Target column does not belong to this database.");
122
+ }
123
+ // The auto-created "Source" tag is internal row-tagging, never a writable
124
+ // bind target.
125
+ if (
126
+ property.name === SOURCE_TAG_PROPERTY_NAME &&
127
+ property.type === "select"
128
+ ) {
129
+ throw new Error(
130
+ "The Source tag column can't be bound to a source field.",
131
+ );
132
+ }
133
+ // Don't silently repoint a field that's already feeding another column —
134
+ // that would orphan the old column's materialized values. Require an
135
+ // explicit unbind first. (Re-binding to the SAME column is an idempotent
136
+ // refresh and is allowed.)
137
+ if (field.propertyId && field.propertyId !== property.id) {
138
+ throw new Error(
139
+ "This source field is already bound to another column. Unbind it first.",
140
+ );
141
+ }
142
+ // At most one field per source per column: a column reads one value per row,
143
+ // and a row belongs to one source. Enforce server-side, not just in the UI.
144
+ const [conflictingField] = await db
145
+ .select({ id: schema.contentDatabaseSourceFields.id })
146
+ .from(schema.contentDatabaseSourceFields)
147
+ .where(
148
+ and(
149
+ eq(schema.contentDatabaseSourceFields.sourceId, source.id),
150
+ eq(schema.contentDatabaseSourceFields.propertyId, property.id),
151
+ ne(schema.contentDatabaseSourceFields.id, field.id),
152
+ ),
153
+ );
154
+ if (conflictingField) {
155
+ throw new Error(
156
+ "This source already feeds this column from another field. Unbind it first.",
157
+ );
158
+ }
159
+ // Only type-compatible fields can share a column. A `text` column is a
160
+ // permissive target for SCALAR fields; a multi-value (list) field would be
161
+ // lossily stringified, so it needs a matching list/multi-select column.
162
+ // Otherwise the field's derived type must equal the column's type.
163
+ const fieldType = propertyTypeForSourceField(field.sourceFieldType);
164
+ const fieldIsMultiValue = [
165
+ "list",
166
+ "array",
167
+ "tags",
168
+ "multi_select",
169
+ ].includes(field.sourceFieldType.trim().toLowerCase());
170
+ if (property.type === "text") {
171
+ if (fieldIsMultiValue) {
172
+ throw new Error(
173
+ "A multi-value source field can't be bound to a text column.",
174
+ );
175
+ }
176
+ } else if (property.type !== fieldType) {
177
+ throw new Error(
178
+ `Field type "${fieldType}" is not compatible with the "${property.type}" column.`,
179
+ );
180
+ }
181
+
182
+ await db
183
+ .update(schema.contentDatabaseSourceFields)
184
+ .set({
185
+ propertyId: property.id,
186
+ localFieldKey: property.id,
187
+ mappingType: "property",
188
+ updatedAt: now,
189
+ })
190
+ .where(eq(schema.contentDatabaseSourceFields.id, field.id));
191
+ await db
192
+ .update(schema.contentDatabaseSources)
193
+ .set({ updatedAt: now })
194
+ .where(eq(schema.contentDatabaseSources.id, source.id));
195
+
196
+ // Backfill the column with this source's per-row values. A federated
197
+ // secondary's rows carry no local document (the read path overlays them),
198
+ // so only materialize for document-backed sources.
199
+ let federationRole: string | null = null;
200
+ try {
201
+ const parsed = JSON.parse(source.metadataJson ?? "{}") as {
202
+ federation?: { role?: string };
203
+ };
204
+ federationRole = parsed.federation?.role ?? null;
205
+ } catch {
206
+ federationRole = null;
207
+ }
208
+ if (federationRole !== "secondary") {
209
+ const sourceRows = await db
210
+ .select()
211
+ .from(schema.contentDatabaseSourceRows)
212
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
213
+ const itemValues = sourceFieldPropertyValuesFromRows(
214
+ sourceRows,
215
+ field.sourceFieldKey,
216
+ property.type,
217
+ );
218
+ // Clear this column's values for ALL of this source's rows first — not
219
+ // just the rows that now have a value — so a row whose new bound field is
220
+ // empty doesn't keep showing a stale/previous value. Then write the
221
+ // non-empty ones. (This source owns these documents' values for the row-
222
+ // union, so clearing them is safe.)
223
+ const sourceDocumentIds = sourceRows
224
+ .map((row) => row.documentId)
225
+ .filter((id): id is string => Boolean(id));
226
+ if (sourceDocumentIds.length > 0) {
227
+ await db
228
+ .delete(schema.documentPropertyValues)
229
+ .where(
230
+ and(
231
+ eq(schema.documentPropertyValues.propertyId, property.id),
232
+ inArray(
233
+ schema.documentPropertyValues.documentId,
234
+ sourceDocumentIds,
235
+ ),
236
+ ),
237
+ );
238
+ }
239
+ if (itemValues.length > 0) {
240
+ await db.insert(schema.documentPropertyValues).values(
241
+ itemValues.map((row) => ({
242
+ id: nanoid(),
243
+ ownerEmail: database.ownerEmail,
244
+ documentId: row.documentId,
245
+ propertyId: property.id,
246
+ valueJson: serializePropertyValue(row.value),
247
+ createdAt: now,
248
+ updatedAt: now,
249
+ })),
250
+ );
251
+ }
252
+ }
253
+
254
+ return getContentDatabaseResponse(database.id);
255
+ },
256
+ });