@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
@@ -31,6 +31,12 @@ import type {
31
31
  DocumentProperty,
32
32
  DocumentPropertyValue,
33
33
  } from "../shared/api.js";
34
+ import {
35
+ parsePropertyOptions,
36
+ serializePropertyOptions,
37
+ serializePropertyValue,
38
+ type DocumentPropertyOptionColor,
39
+ } from "../shared/properties.js";
34
40
  import { sanitizeNormalizationFormula } from "../shared/properties.js";
35
41
  import {
36
42
  readBuilderCmsContentEntries,
@@ -85,6 +91,8 @@ type SourceMetadataRecord = {
85
91
  pushMode?: ContentDatabaseSourcePushMode;
86
92
  pushModeLabel?: string | null;
87
93
  pushModeDescription?: string | null;
94
+ writeMode?: ContentDatabaseSource["metadata"]["writeMode"];
95
+ allowPublicationTransitions?: boolean;
88
96
  notes?: string | null;
89
97
  readMode?: string | null;
90
98
  liveReadConfigured?: boolean;
@@ -381,17 +389,10 @@ function reviewedChangeSet(args: {
381
389
  riskLevel = maxRisk(riskLevel, "high");
382
390
  riskReasons.push("external write");
383
391
  }
384
- if (args.changeSet.pushMode === "publish") {
392
+ if (!args.changeSet.localOnly && args.changeSet.pushMode === "publish") {
385
393
  riskLevel = maxRisk(riskLevel, "high");
386
394
  riskReasons.push("publish mode");
387
395
  }
388
- if (
389
- args.changeSet.direction === "outbound" &&
390
- normalizeCapabilities(args.source.capabilitiesJson).liveWritesEnabled
391
- ) {
392
- riskLevel = maxRisk(riskLevel, "high");
393
- riskReasons.push("live writes enabled");
394
- }
395
396
 
396
397
  const sourceRow = args.changeSet.documentId
397
398
  ? args.rowByDocumentId.get(args.changeSet.documentId)
@@ -417,11 +418,61 @@ function reviewedChangeSet(args: {
417
418
  };
418
419
  }
419
420
 
421
+ // Stable, key-order-insensitive serialization so two same-shape property values
422
+ // (source baseline vs local) don't false-diff purely on key order.
423
+ function stableValueString(value: unknown): string {
424
+ if (value === null || value === undefined) return "null";
425
+ if (Array.isArray(value)) {
426
+ return `[${value.map(stableValueString).join(",")}]`;
427
+ }
428
+ if (typeof value === "object") {
429
+ const record = value as Record<string, unknown>;
430
+ return `{${Object.keys(record)
431
+ .sort()
432
+ .map((key) => `${JSON.stringify(key)}:${stableValueString(record[key])}`)
433
+ .join(",")}}`;
434
+ }
435
+ return JSON.stringify(value);
436
+ }
437
+
438
+ // Equal when both normalize the same. null/undefined/"" are all "empty"; strings
439
+ // are trimmed; objects compared by stable serialization.
440
+ function sameSourceFieldValue(a: unknown, b: unknown): boolean {
441
+ const normalize = (value: unknown) => {
442
+ if (value === null || value === undefined) return "";
443
+ if (typeof value === "string") return value.trim();
444
+ return stableValueString(value);
445
+ };
446
+ return normalize(a) === normalize(b);
447
+ }
448
+
420
449
  export function buildBuilderLocalOutboundChangeSets(args: {
421
450
  source: ContentDatabaseSourceRowDb;
422
451
  rowRows: ContentDatabaseSourceRecordRowDb[];
423
452
  documentTitleById: Map<string, string>;
424
453
  storedChangeSets: ContentDatabaseSourceChangeSet[];
454
+ // Optional inputs that enable new-row creates. When omitted (e.g. legacy
455
+ // callers/tests) the function behaves exactly as before (title diffs only).
456
+ databaseItems?: Array<{ databaseItemId: string; documentId: string }>;
457
+ localValuesByDocument?: Map<string, Map<string, unknown>>;
458
+ writableFields?: Array<{
459
+ propertyId: string | null;
460
+ localFieldKey: string;
461
+ sourceFieldKey: string;
462
+ sourceFieldLabel: string;
463
+ }>;
464
+ // Row-union scoping (multi-source). Documents owned by ANOTHER source must
465
+ // never be create candidates for this one — each row belongs to exactly one
466
+ // collection. And a truly unsourced ("Local") row creates only against the
467
+ // primary, not every attached collection. Both default to the single-source
468
+ // behavior when omitted (no other owners; creates allowed).
469
+ otherSourceDocumentIds?: Set<string>;
470
+ allowUnsourcedCreates?: boolean;
471
+ // Per-document ownership from the visible "Source" select tag (documentId →
472
+ // owning sourceId). A new, still-unlinked row tagged for a specific
473
+ // collection is adopted as a create_draft by THAT collection only; an
474
+ // untagged / "Local" row falls back to the primary (allowUnsourcedCreates).
475
+ taggedSourceByDocumentId?: Map<string, string>;
425
476
  }): ContentDatabaseSourceChangeSet[] {
426
477
  if (normalizeSourceType(args.source.sourceType) !== "builder-cms") return [];
427
478
 
@@ -442,19 +493,45 @@ export function buildBuilderLocalOutboundChangeSets(args: {
442
493
 
443
494
  const sourceTitle = row.sourceDisplayKey.trim();
444
495
  const localTitle = args.documentTitleById.get(row.documentId)?.trim() ?? "";
445
- if (!localTitle || localTitle === sourceTitle) continue;
446
-
447
- const fieldChanges: ContentDatabaseSourceFieldChange[] = [
448
- {
496
+ const fieldChanges: ContentDatabaseSourceFieldChange[] = [];
497
+ if (localTitle && localTitle !== sourceTitle) {
498
+ fieldChanges.push({
449
499
  propertyId: null,
450
500
  propertyName: "Title",
451
501
  localFieldKey: "title",
452
502
  sourceFieldKey: "data.title",
453
503
  currentValue: sourceTitle,
454
504
  proposedValue: localTitle,
455
- },
456
- ];
457
- const matchingStoredChange = args.storedChangeSets.some((changeSet) => {
505
+ });
506
+ }
507
+ // Diff every mapped property field: local value vs the synced source
508
+ // baseline (same-shape DocumentPropertyValue, stable compare). An absent
509
+ // local value means "not loaded", not "cleared" — skip it.
510
+ const rowLocalValues = args.localValuesByDocument?.get(row.documentId);
511
+ if (rowLocalValues) {
512
+ const rowSourceValues =
513
+ parseObject<Record<string, DocumentPropertyValue>>(
514
+ row.sourceValuesJson,
515
+ ) ?? {};
516
+ for (const field of args.writableFields ?? []) {
517
+ if (!rowLocalValues.has(field.localFieldKey)) continue;
518
+ const localValue = rowLocalValues.get(field.localFieldKey);
519
+ const baseValue = rowSourceValues[field.sourceFieldKey];
520
+ if (sameSourceFieldValue(localValue, baseValue)) continue;
521
+ fieldChanges.push({
522
+ propertyId: field.propertyId,
523
+ propertyName: field.sourceFieldLabel,
524
+ localFieldKey: field.localFieldKey,
525
+ sourceFieldKey: field.sourceFieldKey,
526
+ currentValue: (baseValue ?? null) as DocumentPropertyValue,
527
+ proposedValue: localValue as DocumentPropertyValue,
528
+ });
529
+ }
530
+ }
531
+ if (fieldChanges.length === 0) continue;
532
+ // Skip if this row already has a live (non-rejected/applied) stored outbound
533
+ // autosave change-set — the stored one is what's being reviewed/pushed.
534
+ const matchesStoredChange = args.storedChangeSets.some((changeSet) => {
458
535
  if (
459
536
  changeSet.direction !== "outbound" ||
460
537
  changeSet.documentId !== row.documentId ||
@@ -464,14 +541,16 @@ export function buildBuilderLocalOutboundChangeSets(args: {
464
541
  ) {
465
542
  return false;
466
543
  }
467
- return changeSet.fieldChanges.some(
468
- (fieldChange) =>
469
- fieldChange.localFieldKey === "title" &&
470
- fieldChange.currentValue === sourceTitle &&
471
- fieldChange.proposedValue === localTitle,
544
+ return changeSet.fieldChanges.some((stored) =>
545
+ fieldChanges.some(
546
+ (change) =>
547
+ change.localFieldKey === stored.localFieldKey &&
548
+ sameSourceFieldValue(change.currentValue, stored.currentValue) &&
549
+ sameSourceFieldValue(change.proposedValue, stored.proposedValue),
550
+ ),
472
551
  );
473
552
  });
474
- if (matchingStoredChange) continue;
553
+ if (matchesStoredChange) continue;
475
554
 
476
555
  const now = new Date().toISOString();
477
556
  pending.push({
@@ -483,7 +562,10 @@ export function buildBuilderLocalOutboundChangeSets(args: {
483
562
  state: "pending_push",
484
563
  pushMode: "autosave",
485
564
  localOnly: true,
486
- summary: `Pending local Builder CMS title change for "${localTitle}".`,
565
+ summary:
566
+ fieldChanges.length === 1 && fieldChanges[0]?.localFieldKey === "title"
567
+ ? `Pending local Builder CMS title change for "${localTitle}".`
568
+ : `Pending local Builder CMS changes for "${localTitle || sourceTitle}".`,
487
569
  fieldChanges,
488
570
  bodyChange: null,
489
571
  riskLevel: "low",
@@ -496,6 +578,91 @@ export function buildBuilderLocalOutboundChangeSets(args: {
496
578
  });
497
579
  }
498
580
 
581
+ // New-row creates: a local database item NOT linked to a Builder entry (no
582
+ // source row) and with a non-empty title becomes a create_draft change-set.
583
+ // No baseline comparison here — we send the local values; the create_draft
584
+ // effect (derived from a null target entryId) writes the entry as a draft.
585
+ if (args.databaseItems && args.databaseItems.length > 0) {
586
+ const linkedDocumentIds = new Set(
587
+ args.rowRows.map((row) => row.documentId),
588
+ );
589
+ const documentIdsWithStoredChange = new Set(
590
+ args.storedChangeSets
591
+ .filter(
592
+ (changeSet) =>
593
+ changeSet.direction === "outbound" &&
594
+ changeSet.state !== "applied" &&
595
+ changeSet.state !== "rejected",
596
+ )
597
+ .map((changeSet) => changeSet.documentId),
598
+ );
599
+ const allowUnsourcedCreates = args.allowUnsourcedCreates ?? true;
600
+ for (const item of args.databaseItems) {
601
+ if (linkedDocumentIds.has(item.documentId)) continue;
602
+ // Owned by another collection's row identity — not this source's to create.
603
+ if (args.otherSourceDocumentIds?.has(item.documentId)) continue;
604
+ const taggedSourceId = args.taggedSourceByDocumentId?.get(
605
+ item.documentId,
606
+ );
607
+ if (taggedSourceId) {
608
+ // Explicitly tagged for a collection via the "Source" property: only
609
+ // that collection adopts it (regardless of primary/non-primary).
610
+ if (taggedSourceId !== args.source.id) continue;
611
+ } else if (!allowUnsourcedCreates) {
612
+ // Untagged / "Local": only the primary adopts it as a create; other
613
+ // collections leave it alone until it's explicitly assigned to them.
614
+ continue;
615
+ }
616
+ if (documentIdsWithStoredChange.has(item.documentId)) continue;
617
+ const title = args.documentTitleById.get(item.documentId)?.trim() ?? "";
618
+ if (!title) continue;
619
+ const localValues = args.localValuesByDocument?.get(item.documentId);
620
+ const fieldChanges: ContentDatabaseSourceFieldChange[] = [
621
+ {
622
+ propertyId: null,
623
+ propertyName: "Title",
624
+ localFieldKey: "title",
625
+ sourceFieldKey: "data.title",
626
+ currentValue: null,
627
+ proposedValue: title,
628
+ },
629
+ ];
630
+ for (const field of args.writableFields ?? []) {
631
+ if (!localValues?.has(field.localFieldKey)) continue;
632
+ fieldChanges.push({
633
+ propertyId: field.propertyId,
634
+ propertyName: field.sourceFieldLabel,
635
+ localFieldKey: field.localFieldKey,
636
+ sourceFieldKey: field.sourceFieldKey,
637
+ currentValue: null,
638
+ proposedValue: (localValues.get(field.localFieldKey) ??
639
+ null) as DocumentPropertyValue,
640
+ });
641
+ }
642
+ const now = new Date().toISOString();
643
+ pending.push({
644
+ id: `local-pending-create-${item.databaseItemId}`,
645
+ databaseItemId: item.databaseItemId,
646
+ documentId: item.documentId,
647
+ kind: "field_update",
648
+ direction: "outbound",
649
+ state: "pending_push",
650
+ pushMode: "autosave",
651
+ localOnly: true,
652
+ summary: `Pending new Builder entry "${title}".`,
653
+ fieldChanges,
654
+ bodyChange: null,
655
+ riskLevel: "low",
656
+ riskReasons: ["new Builder entry (create as draft)"],
657
+ conflictState: "none",
658
+ reviewEvents: [],
659
+ executions: [],
660
+ createdAt: now,
661
+ updatedAt: now,
662
+ });
663
+ }
664
+ }
665
+
499
666
  return pending;
500
667
  }
501
668
 
@@ -542,11 +709,52 @@ export async function getContentDatabaseSourceSnapshot(
542
709
  .select()
543
710
  .from(schema.contentDatabaseSources)
544
711
  .where(eq(schema.contentDatabaseSources.databaseId, database.id))
545
- .orderBy(asc(schema.contentDatabaseSources.createdAt));
712
+ .orderBy(
713
+ asc(schema.contentDatabaseSources.createdAt),
714
+ asc(schema.contentDatabaseSources.id),
715
+ );
546
716
  if (!source) return null;
547
717
  return loadSourceSnapshot(source, database);
548
718
  }
549
719
 
720
+ /**
721
+ * Load one specific attached source by id (scoped to the database). Multi-source
722
+ * write paths use this so an action can target a non-primary source; single-source
723
+ * callers keep using {@link getContentDatabaseSourceSnapshot} (the primary).
724
+ */
725
+ export async function getContentDatabaseSourceSnapshotById(
726
+ database: ContentDatabaseRow | ContentDatabase,
727
+ sourceId: string,
728
+ ): Promise<ContentDatabaseSource | null> {
729
+ const db = getDb();
730
+ const [source] = await db
731
+ .select()
732
+ .from(schema.contentDatabaseSources)
733
+ .where(
734
+ and(
735
+ eq(schema.contentDatabaseSources.databaseId, database.id),
736
+ eq(schema.contentDatabaseSources.id, sourceId),
737
+ ),
738
+ );
739
+ if (!source) return null;
740
+ return loadSourceSnapshot(source, database);
741
+ }
742
+
743
+ /**
744
+ * Resolve the source an action should operate on: the explicit `sourceId` when
745
+ * given (multi-source), otherwise the primary (back-compat single-source). The
746
+ * default path is byte-for-byte the old behavior, so existing callers that omit
747
+ * `sourceId` are unaffected.
748
+ */
749
+ export async function getContentDatabaseSourceSnapshotForWrite(
750
+ database: ContentDatabaseRow | ContentDatabase,
751
+ sourceId?: string | null,
752
+ ): Promise<ContentDatabaseSource | null> {
753
+ return sourceId
754
+ ? getContentDatabaseSourceSnapshotById(database, sourceId)
755
+ : getContentDatabaseSourceSnapshot(database);
756
+ }
757
+
550
758
  /**
551
759
  * Load every source attached to a database (oldest first → `[0]` is the
552
760
  * primary). Federation joins read this; single-source callers keep using
@@ -563,7 +771,10 @@ export async function getAllContentDatabaseSourceSnapshots(
563
771
  .select()
564
772
  .from(schema.contentDatabaseSources)
565
773
  .where(eq(schema.contentDatabaseSources.databaseId, database.id))
566
- .orderBy(asc(schema.contentDatabaseSources.createdAt));
774
+ .orderBy(
775
+ asc(schema.contentDatabaseSources.createdAt),
776
+ asc(schema.contentDatabaseSources.id),
777
+ );
567
778
  const snapshots: ContentDatabaseSource[] = [];
568
779
  for (const source of sources) {
569
780
  snapshots.push(await loadSourceSnapshot(source, database));
@@ -647,8 +858,32 @@ async function loadSourceSnapshot(
647
858
  executions.push(serializeExecution(row));
648
859
  executionsByChangeSetId.set(row.changeSetId, executions);
649
860
  }
861
+ const isBuilderSource =
862
+ normalizeSourceType(source.sourceType) === "builder-cms";
863
+ // For Builder sources, load ALL database items (not just synced source rows)
864
+ // so brand-new local rows (no source link) can become create_draft change-sets.
865
+ const databaseItemRows = isBuilderSource
866
+ ? await db
867
+ .select({
868
+ id: schema.contentDatabaseItems.id,
869
+ documentId: schema.contentDatabaseItems.documentId,
870
+ })
871
+ .from(schema.contentDatabaseItems)
872
+ .where(
873
+ and(
874
+ eq(schema.contentDatabaseItems.databaseId, database.id),
875
+ eq(schema.contentDatabaseItems.ownerEmail, source.ownerEmail),
876
+ ),
877
+ )
878
+ : [];
879
+ const allDocumentIds = Array.from(
880
+ new Set([
881
+ ...rowRows.map((row) => row.documentId),
882
+ ...databaseItemRows.map((item) => item.documentId),
883
+ ]),
884
+ );
650
885
  const rowDocuments =
651
- rowRows.length > 0
886
+ allDocumentIds.length > 0
652
887
  ? await db
653
888
  .select({
654
889
  id: schema.documents.id,
@@ -657,10 +892,7 @@ async function loadSourceSnapshot(
657
892
  .from(schema.documents)
658
893
  .where(
659
894
  and(
660
- inArray(
661
- schema.documents.id,
662
- rowRows.map((row) => row.documentId),
663
- ),
895
+ inArray(schema.documents.id, allDocumentIds),
664
896
  eq(schema.documents.ownerEmail, source.ownerEmail),
665
897
  ),
666
898
  )
@@ -668,11 +900,123 @@ async function loadSourceSnapshot(
668
900
  const documentTitleById = new Map(
669
901
  rowDocuments.map((document) => [document.id, document.title]),
670
902
  );
903
+ const propertyValueRows =
904
+ isBuilderSource && allDocumentIds.length > 0
905
+ ? await db
906
+ .select({
907
+ documentId: schema.documentPropertyValues.documentId,
908
+ propertyId: schema.documentPropertyValues.propertyId,
909
+ valueJson: schema.documentPropertyValues.valueJson,
910
+ })
911
+ .from(schema.documentPropertyValues)
912
+ .where(
913
+ and(
914
+ inArray(schema.documentPropertyValues.documentId, allDocumentIds),
915
+ eq(schema.documentPropertyValues.ownerEmail, source.ownerEmail),
916
+ ),
917
+ )
918
+ : [];
919
+ const localValuesByDocument = new Map<string, Map<string, unknown>>();
920
+ for (const valueRow of propertyValueRows) {
921
+ let byField = localValuesByDocument.get(valueRow.documentId);
922
+ if (!byField) {
923
+ byField = new Map<string, unknown>();
924
+ localValuesByDocument.set(valueRow.documentId, byField);
925
+ }
926
+ let parsed: unknown = null;
927
+ try {
928
+ parsed = JSON.parse(valueRow.valueJson);
929
+ } catch {
930
+ parsed = null;
931
+ }
932
+ byField.set(valueRow.propertyId, parsed);
933
+ }
934
+ const writableFields = fieldRows
935
+ .filter((row) => row.mappingType === "property")
936
+ .map((row) => ({
937
+ propertyId: row.propertyId ?? null,
938
+ localFieldKey: row.localFieldKey,
939
+ sourceFieldKey: row.sourceFieldKey,
940
+ sourceFieldLabel: row.sourceFieldLabel,
941
+ }));
942
+ // Row-union ownership scoping (Builder only). Determine which documents belong
943
+ // to OTHER sources and whether this source is the primary (oldest), so the
944
+ // create-candidate logic never claims another collection's rows and unsourced
945
+ // "Local" rows only create against the primary. Single-source: no other
946
+ // sources ⇒ empty set, isPrimary ⇒ identical to the old behavior.
947
+ let otherSourceDocumentIds = new Set<string>();
948
+ let isPrimarySource = true;
949
+ let taggedSourceByDocumentId = new Map<string, string>();
950
+ if (isBuilderSource) {
951
+ const dbSources = await db
952
+ .select({
953
+ id: schema.contentDatabaseSources.id,
954
+ sourceName: schema.contentDatabaseSources.sourceName,
955
+ })
956
+ .from(schema.contentDatabaseSources)
957
+ .where(eq(schema.contentDatabaseSources.databaseId, database.id))
958
+ // Same (createdAt, id) ordering as getExistingSource /
959
+ // getContentDatabaseSourceSnapshot, so "primary" here is definitionally
960
+ // the same source the write path treats as primary — never a different
961
+ // pick on a createdAt tie.
962
+ .orderBy(
963
+ asc(schema.contentDatabaseSources.createdAt),
964
+ asc(schema.contentDatabaseSources.id),
965
+ );
966
+ isPrimarySource = dbSources[0]?.id === source.id;
967
+ const otherSourceIds = dbSources
968
+ .map((row) => row.id)
969
+ .filter((id) => id !== source.id);
970
+ if (otherSourceIds.length > 0) {
971
+ const ownedRows = await db
972
+ .select({ documentId: schema.contentDatabaseSourceRows.documentId })
973
+ .from(schema.contentDatabaseSourceRows)
974
+ .where(
975
+ inArray(schema.contentDatabaseSourceRows.sourceId, otherSourceIds),
976
+ );
977
+ otherSourceDocumentIds = new Set(ownedRows.map((row) => row.documentId));
978
+ }
979
+ // Multi-source: a row's visible "Source" tag value IS its owning source id
980
+ // (the Source option id equals the source id), so adoption is pure id
981
+ // matching — no source-name hop, immune to duplicate names or a "Local"
982
+ // collision. The "Local" sentinel isn't a real source id, so untagged rows
983
+ // fall through to the primary-only path.
984
+ if (dbSources.length > 1) {
985
+ const [sourceProp] = await db
986
+ .select({ id: schema.documentPropertyDefinitions.id })
987
+ .from(schema.documentPropertyDefinitions)
988
+ .where(
989
+ and(
990
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
991
+ eq(schema.documentPropertyDefinitions.name, SOURCE_PROPERTY_NAME),
992
+ eq(schema.documentPropertyDefinitions.type, "select"),
993
+ ),
994
+ );
995
+ if (sourceProp) {
996
+ const validSourceIds = new Set(dbSources.map((row) => row.id));
997
+ for (const [documentId, byProperty] of localValuesByDocument) {
998
+ const optionId = byProperty.get(sourceProp.id);
999
+ if (typeof optionId === "string" && validSourceIds.has(optionId)) {
1000
+ taggedSourceByDocumentId.set(documentId, optionId);
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+ }
671
1006
  const localOutboundChangeSets = buildBuilderLocalOutboundChangeSets({
672
1007
  source,
673
1008
  rowRows,
674
1009
  documentTitleById,
675
1010
  storedChangeSets,
1011
+ databaseItems: databaseItemRows.map((item) => ({
1012
+ databaseItemId: item.id,
1013
+ documentId: item.documentId,
1014
+ })),
1015
+ localValuesByDocument,
1016
+ writableFields,
1017
+ otherSourceDocumentIds,
1018
+ allowUnsourcedCreates: isPrimarySource,
1019
+ taggedSourceByDocumentId,
676
1020
  });
677
1021
  const rowByDocumentId = new Map(rowRows.map((row) => [row.documentId, row]));
678
1022
  const changeSets = [...storedChangeSets, ...localOutboundChangeSets].map(
@@ -686,6 +1030,16 @@ async function loadSourceSnapshot(
686
1030
  }),
687
1031
  );
688
1032
  const metadata = parseObject<SourceMetadataRecord>(source.metadataJson) ?? {};
1033
+ const normalizedWriteMode =
1034
+ metadata.writeMode === "read_only" ||
1035
+ metadata.writeMode === "stage_only" ||
1036
+ metadata.writeMode === "publish_updates"
1037
+ ? metadata.writeMode
1038
+ : undefined;
1039
+ const capabilities = normalizeCapabilities(source.capabilitiesJson);
1040
+ if (normalizedWriteMode) {
1041
+ capabilities.liveWritesEnabled = normalizedWriteMode !== "read_only";
1042
+ }
689
1043
 
690
1044
  // A local-table source shows the target database's *live* title, so renaming
691
1045
  // the underlying table is reflected here instead of the name frozen at attach.
@@ -709,7 +1063,7 @@ async function loadSourceSnapshot(
709
1063
  lastRefreshedAt: source.lastRefreshedAt,
710
1064
  lastSourceUpdatedAt: source.lastSourceUpdatedAt,
711
1065
  lastError: source.lastError,
712
- capabilities: normalizeCapabilities(source.capabilitiesJson),
1066
+ capabilities,
713
1067
  metadata: {
714
1068
  primaryKey: metadata.primaryKey ?? "id",
715
1069
  titleField: metadata.titleField ?? "title",
@@ -717,6 +1071,9 @@ async function loadSourceSnapshot(
717
1071
  pushMode: metadata.pushMode ?? "none",
718
1072
  pushModeLabel: metadata.pushModeLabel ?? null,
719
1073
  pushModeDescription: metadata.pushModeDescription ?? null,
1074
+ writeMode: normalizedWriteMode,
1075
+ allowPublicationTransitions:
1076
+ metadata.allowPublicationTransitions === true,
720
1077
  notes: metadata.notes ?? null,
721
1078
  readMode: metadata.readMode ?? null,
722
1079
  liveReadConfigured: metadata.liveReadConfigured === true,
@@ -977,47 +1334,65 @@ export async function seedMockSourceFields(args: {
977
1334
  createdAt: args.now,
978
1335
  updatedAt: args.now,
979
1336
  },
980
- ...args.properties.map((property) => ({
981
- id: crypto.randomUUID(),
982
- ownerEmail: args.ownerEmail,
983
- sourceId: args.sourceId,
984
- propertyId: property.definition.id,
985
- localFieldKey: property.definition.id,
986
- sourceFieldKey: isBuilder
987
- ? builderCmsSourceFieldKey(
988
- property.definition.id,
989
- property.definition.name,
990
- )
991
- : `fields.${slugifySourceField(property.definition.name)}`,
992
- sourceFieldLabel: property.definition.name,
993
- sourceFieldType: property.definition.type,
994
- mappingType: "property",
995
- writeOwner:
996
- property.definition.type === "created_time" ||
997
- property.definition.type === "created_by" ||
998
- property.definition.type === "last_edited_time" ||
999
- property.definition.type === "last_edited_by"
1000
- ? "derived"
1001
- : isBuilder
1002
- ? "source"
1003
- : "local",
1004
- readOnly:
1005
- property.definition.type === "created_time" ||
1006
- property.definition.type === "created_by" ||
1007
- property.definition.type === "last_edited_time" ||
1008
- property.definition.type === "last_edited_by"
1009
- ? 1
1010
- : 0,
1011
- provenance:
1012
- property.definition.type === "formula" ||
1013
- property.definition.type === "rollup"
1014
- ? "derived"
1015
- : "source field",
1016
- freshness: "fresh",
1017
- lastSyncedAt: args.now,
1018
- createdAt: args.now,
1019
- updatedAt: args.now,
1020
- })),
1337
+ // The auto-created "Source" property is internal row-tagging (which
1338
+ // collection a row belongs to). It must NEVER become a writable Builder
1339
+ // source field — otherwise its local option-id value diffs against an
1340
+ // absent baseline and every row shows a phantom pending change, and a push
1341
+ // would try to write the internal tag to Builder. Match the SAME shape
1342
+ // ensureDatabaseSourceProperty uses to identify it (a `select` named
1343
+ // "Source") and only for Builder sources, so a user's own field happening
1344
+ // to be named "Source" — or any non-Builder/local-table source — is left
1345
+ // untouched.
1346
+ ...args.properties
1347
+ .filter(
1348
+ (property) =>
1349
+ !(
1350
+ isBuilder &&
1351
+ property.definition.name === SOURCE_PROPERTY_NAME &&
1352
+ property.definition.type === "select"
1353
+ ),
1354
+ )
1355
+ .map((property) => ({
1356
+ id: crypto.randomUUID(),
1357
+ ownerEmail: args.ownerEmail,
1358
+ sourceId: args.sourceId,
1359
+ propertyId: property.definition.id,
1360
+ localFieldKey: property.definition.id,
1361
+ sourceFieldKey: isBuilder
1362
+ ? builderCmsSourceFieldKey(
1363
+ property.definition.id,
1364
+ property.definition.name,
1365
+ )
1366
+ : `fields.${slugifySourceField(property.definition.name)}`,
1367
+ sourceFieldLabel: property.definition.name,
1368
+ sourceFieldType: property.definition.type,
1369
+ mappingType: "property",
1370
+ writeOwner:
1371
+ property.definition.type === "created_time" ||
1372
+ property.definition.type === "created_by" ||
1373
+ property.definition.type === "last_edited_time" ||
1374
+ property.definition.type === "last_edited_by"
1375
+ ? "derived"
1376
+ : isBuilder
1377
+ ? "source"
1378
+ : "local",
1379
+ readOnly:
1380
+ property.definition.type === "created_time" ||
1381
+ property.definition.type === "created_by" ||
1382
+ property.definition.type === "last_edited_time" ||
1383
+ property.definition.type === "last_edited_by"
1384
+ ? 1
1385
+ : 0,
1386
+ provenance:
1387
+ property.definition.type === "formula" ||
1388
+ property.definition.type === "rollup"
1389
+ ? "derived"
1390
+ : "source field",
1391
+ freshness: "fresh",
1392
+ lastSyncedAt: args.now,
1393
+ createdAt: args.now,
1394
+ updatedAt: args.now,
1395
+ })),
1021
1396
  ];
1022
1397
  if (isBuilder) {
1023
1398
  const existingSourceFieldKeys = new Set(
@@ -1528,6 +1903,11 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
1528
1903
  now: string;
1529
1904
  sourceTable: string;
1530
1905
  existingSourceRows?: ContentDatabaseSourceRecordRowDb[];
1906
+ // When importing an ADDITIONAL source (row-union), two collections may share
1907
+ // a title legitimately, so the cross-database title dedup must be skipped —
1908
+ // per-source re-import idempotency is still handled by
1909
+ // builderCmsEntryAlreadyRepresented (existingSourceRows).
1910
+ skipTitleDedup?: boolean;
1531
1911
  }) {
1532
1912
  if (args.entries.length === 0) return 0;
1533
1913
  const db = getDb();
@@ -1580,7 +1960,7 @@ export async function importBuilderCmsEntriesAsDatabaseItems(args: {
1580
1960
 
1581
1961
  const title = entry.title.trim() || entry.id;
1582
1962
  const titleKey = title.toLowerCase();
1583
- if (existingTitles.has(titleKey)) continue;
1963
+ if (!args.skipTitleDedup && existingTitles.has(titleKey)) continue;
1584
1964
  existingTitles.add(titleKey);
1585
1965
 
1586
1966
  const documentId = nanoid();
@@ -1690,12 +2070,34 @@ export async function resyncBuilderCmsSourceSnapshot(args: {
1690
2070
  builderRead.state === "live" ? builderRead.entries : [],
1691
2071
  now: args.now,
1692
2072
  });
2073
+ // Row-union: a resync must only (re)link items that BELONG to this source —
2074
+ // never claim every database item. With a single source, all items belong to
2075
+ // it (back-compat). With multiple sources, link only this source's
2076
+ // remote-backed rows when the read is live (this self-heals any prior
2077
+ // over-claim, since rows are deleted then reseeded); when offline, preserve
2078
+ // just the rows already owned so nothing is orphaned. New / "Local" /
2079
+ // other-collection rows stay unlinked, so the Source-tag create path can
2080
+ // adopt them into the right collection.
2081
+ const databaseSourceCount = (
2082
+ await db
2083
+ .select({ id: schema.contentDatabaseSources.id })
2084
+ .from(schema.contentDatabaseSources)
2085
+ .where(eq(schema.contentDatabaseSources.databaseId, args.database.id))
2086
+ ).length;
2087
+ const itemsToLink =
2088
+ databaseSourceCount > 1
2089
+ ? response.items.filter((item) =>
2090
+ builderRead.state === "live"
2091
+ ? builderEntriesByDocumentId.has(item.document.id)
2092
+ : existingBuilderRows.has(item.document.id),
2093
+ )
2094
+ : response.items;
1693
2095
  await seedMockSourceRows({
1694
2096
  sourceId: args.source.id,
1695
2097
  ownerEmail: args.database.ownerEmail,
1696
2098
  sourceType: "builder-cms",
1697
2099
  sourceTable: args.source.sourceTable,
1698
- items: response.items,
2100
+ items: itemsToLink,
1699
2101
  now: args.now,
1700
2102
  existingBuilderRows,
1701
2103
  builderEntriesByDocumentId,
@@ -1949,6 +2351,13 @@ export async function seedSecondarySourceFields(args: {
1949
2351
  now: string;
1950
2352
  }) {
1951
2353
  const db = getDb();
2354
+ const existingFields = await db
2355
+ .select()
2356
+ .from(schema.contentDatabaseSourceFields)
2357
+ .where(eq(schema.contentDatabaseSourceFields.sourceId, args.sourceId));
2358
+ const existingBySourceFieldKey = new Map(
2359
+ existingFields.map((field) => [field.sourceFieldKey, field]),
2360
+ );
1952
2361
  await db
1953
2362
  .delete(schema.contentDatabaseSourceFields)
1954
2363
  .where(eq(schema.contentDatabaseSourceFields.sourceId, args.sourceId));
@@ -1958,32 +2367,57 @@ export async function seedSecondarySourceFields(args: {
1958
2367
  normalizeBuilderCmsSourceFieldType(field.type),
1959
2368
  ]),
1960
2369
  );
1961
- const keys = new Set<string>(args.modelFields.map((field) => field.name));
1962
- for (const key of Object.keys(args.sampleEntry?.sourceValues ?? {})) {
1963
- keys.add(key);
2370
+ const modelFieldByName = new Map(
2371
+ args.modelFields.map((field) => [field.name, field]),
2372
+ );
2373
+ const sampleKeys = new Set(Object.keys(args.sampleEntry?.sourceValues ?? {}));
2374
+ const keys = new Set<string>(sampleKeys);
2375
+ for (const field of args.modelFields) {
2376
+ if (sampleKeys.has(field.name)) {
2377
+ keys.add(field.name);
2378
+ } else if (sampleKeys.has(`data.${field.name}`)) {
2379
+ keys.add(`data.${field.name}`);
2380
+ } else {
2381
+ keys.add(field.name);
2382
+ }
1964
2383
  }
1965
2384
  if (keys.size === 0) return;
1966
2385
  await db.insert(schema.contentDatabaseSourceFields).values(
1967
- [...keys].map((key) => ({
1968
- id: crypto.randomUUID(),
1969
- ownerEmail: args.ownerEmail,
1970
- sourceId: args.sourceId,
1971
- propertyId: null,
1972
- localFieldKey: key,
1973
- sourceFieldKey: key,
1974
- sourceFieldLabel:
1975
- args.modelFields.find((field) => field.name === key)?.label ??
1976
- builderCmsModelFieldLabel(key),
1977
- sourceFieldType: fieldTypeByKey.get(key) ?? "text",
1978
- mappingType: "property" as const,
1979
- writeOwner: "source" as const,
1980
- readOnly: 1,
1981
- provenance: "secondary source field",
1982
- freshness: "fresh" as const,
1983
- lastSyncedAt: args.now,
1984
- createdAt: args.now,
1985
- updatedAt: args.now,
1986
- })),
2386
+ [...keys].map((key) => {
2387
+ const unprefixedKey = key.replace(/^data\./, "");
2388
+ const existing =
2389
+ existingBySourceFieldKey.get(key) ??
2390
+ (key.startsWith("data.")
2391
+ ? existingBySourceFieldKey.get(unprefixedKey)
2392
+ : existingBySourceFieldKey.get(`data.${key}`));
2393
+ const modelField =
2394
+ modelFieldByName.get(key) ?? modelFieldByName.get(unprefixedKey);
2395
+ return {
2396
+ id: crypto.randomUUID(),
2397
+ ownerEmail: args.ownerEmail,
2398
+ sourceId: args.sourceId,
2399
+ propertyId: existing?.propertyId ?? null,
2400
+ localFieldKey: existing?.localFieldKey ?? key,
2401
+ sourceFieldKey: key,
2402
+ sourceFieldLabel:
2403
+ modelField?.label ??
2404
+ existing?.sourceFieldLabel ??
2405
+ builderCmsModelFieldLabel(key),
2406
+ sourceFieldType:
2407
+ fieldTypeByKey.get(key) ??
2408
+ fieldTypeByKey.get(unprefixedKey) ??
2409
+ existing?.sourceFieldType ??
2410
+ "text",
2411
+ mappingType: "property" as const,
2412
+ writeOwner: "source" as const,
2413
+ readOnly: 1,
2414
+ provenance: "secondary source field",
2415
+ freshness: "fresh" as const,
2416
+ lastSyncedAt: args.now,
2417
+ createdAt: existing?.createdAt ?? args.now,
2418
+ updatedAt: args.now,
2419
+ };
2420
+ }),
1987
2421
  );
1988
2422
  }
1989
2423
 
@@ -2059,10 +2493,225 @@ export async function getExistingSource(databaseId: string) {
2059
2493
  const [source] = await db
2060
2494
  .select()
2061
2495
  .from(schema.contentDatabaseSources)
2062
- .where(eq(schema.contentDatabaseSources.databaseId, databaseId));
2496
+ .where(eq(schema.contentDatabaseSources.databaseId, databaseId))
2497
+ // Oldest-first so "the source" is deterministically the primary, matching
2498
+ // getContentDatabaseSourceSnapshot. Without this, a multi-source database
2499
+ // could resolve a non-primary source when a caller omits sourceId. The `id`
2500
+ // tie-break keeps the choice stable when two sources share a createdAt
2501
+ // timestamp (no uniqueness guarantee on created_at).
2502
+ .orderBy(
2503
+ asc(schema.contentDatabaseSources.createdAt),
2504
+ asc(schema.contentDatabaseSources.id),
2505
+ );
2506
+ return source ?? null;
2507
+ }
2508
+
2509
+ /** The source DB row for one attached source by id (scoped to the database). */
2510
+ export async function getExistingSourceById(
2511
+ databaseId: string,
2512
+ sourceId: string,
2513
+ ) {
2514
+ const db = getDb();
2515
+ const [source] = await db
2516
+ .select()
2517
+ .from(schema.contentDatabaseSources)
2518
+ .where(
2519
+ and(
2520
+ eq(schema.contentDatabaseSources.databaseId, databaseId),
2521
+ eq(schema.contentDatabaseSources.id, sourceId),
2522
+ ),
2523
+ );
2063
2524
  return source ?? null;
2064
2525
  }
2065
2526
 
2527
+ /** The source DB row for an action: explicit `sourceId` when given, else primary. */
2528
+ export async function getExistingSourceForWrite(
2529
+ databaseId: string,
2530
+ sourceId?: string | null,
2531
+ ) {
2532
+ return sourceId
2533
+ ? getExistingSourceById(databaseId, sourceId)
2534
+ : getExistingSource(databaseId);
2535
+ }
2536
+
2537
+ /** Whether a source for this model (sourceTable) is already attached. */
2538
+ export async function databaseSourceExistsForTable(
2539
+ databaseId: string,
2540
+ sourceTable: string,
2541
+ ): Promise<boolean> {
2542
+ const db = getDb();
2543
+ const [row] = await db
2544
+ .select({ id: schema.contentDatabaseSources.id })
2545
+ .from(schema.contentDatabaseSources)
2546
+ .where(
2547
+ and(
2548
+ eq(schema.contentDatabaseSources.databaseId, databaseId),
2549
+ eq(schema.contentDatabaseSources.sourceTable, sourceTable),
2550
+ ),
2551
+ );
2552
+ return !!row;
2553
+ }
2554
+
2555
+ export const SOURCE_PROPERTY_NAME = "Source";
2556
+ // The "Local" (no collection) option id. A fixed non-UUID sentinel so it never
2557
+ // collides with a source id (which is what every collection option's id is).
2558
+ export const SOURCE_LOCAL_OPTION_ID = "local";
2559
+
2560
+ const SOURCE_OPTION_PALETTE: DocumentPropertyOptionColor[] = [
2561
+ "blue",
2562
+ "green",
2563
+ "orange",
2564
+ "purple",
2565
+ "pink",
2566
+ "yellow",
2567
+ "brown",
2568
+ "red",
2569
+ ];
2570
+
2571
+ /**
2572
+ * Ensure a "Source" select property exists tagging each row with the collection
2573
+ * it belongs to, and (re)set every item's value. Rows with no source binding are
2574
+ * "Local" — the same first-class state a brand-new local row has. Only runs once
2575
+ * a database has 2+ sources (row-union); a single-source database doesn't need
2576
+ * the tag. Option ids are preserved across re-runs so colors/filters stay stable.
2577
+ */
2578
+ export async function ensureDatabaseSourceProperty(args: {
2579
+ database: ContentDatabaseRow;
2580
+ now: string;
2581
+ }) {
2582
+ const db = getDb();
2583
+ const sources = await db
2584
+ .select({
2585
+ id: schema.contentDatabaseSources.id,
2586
+ sourceName: schema.contentDatabaseSources.sourceName,
2587
+ })
2588
+ .from(schema.contentDatabaseSources)
2589
+ .where(eq(schema.contentDatabaseSources.databaseId, args.database.id))
2590
+ .orderBy(asc(schema.contentDatabaseSources.createdAt));
2591
+ if (sources.length < 2) return;
2592
+
2593
+ const [existing] = await db
2594
+ .select()
2595
+ .from(schema.documentPropertyDefinitions)
2596
+ .where(
2597
+ and(
2598
+ eq(schema.documentPropertyDefinitions.databaseId, args.database.id),
2599
+ eq(schema.documentPropertyDefinitions.name, SOURCE_PROPERTY_NAME),
2600
+ eq(schema.documentPropertyDefinitions.type, "select"),
2601
+ ),
2602
+ );
2603
+
2604
+ const priorOptions = existing
2605
+ ? (parsePropertyOptions(existing.optionsJson).options ?? [])
2606
+ : [];
2607
+ // Each source option's id IS the sourceId (and "Local" uses a fixed sentinel
2608
+ // that can't collide with a UUID source id). Resolving a row's tag back to a
2609
+ // source is then pure id matching — no source-name hop — so duplicate display
2610
+ // names or a collection literally named "Local" can never misroute a row.
2611
+ const priorById = new Map(priorOptions.map((option) => [option.id, option]));
2612
+ const options = [
2613
+ ...sources.map((source, index) => ({
2614
+ id: source.id,
2615
+ name: source.sourceName,
2616
+ color:
2617
+ priorById.get(source.id)?.color ??
2618
+ SOURCE_OPTION_PALETTE[index % SOURCE_OPTION_PALETTE.length],
2619
+ })),
2620
+ {
2621
+ id: SOURCE_LOCAL_OPTION_ID,
2622
+ name: "Local",
2623
+ color: (priorById.get(SOURCE_LOCAL_OPTION_ID)?.color ??
2624
+ "gray") as DocumentPropertyOptionColor,
2625
+ },
2626
+ ];
2627
+ const optionsJson = serializePropertyOptions({ options });
2628
+
2629
+ let propertyId: string;
2630
+ if (existing) {
2631
+ propertyId = existing.id;
2632
+ await db
2633
+ .update(schema.documentPropertyDefinitions)
2634
+ .set({ optionsJson, updatedAt: args.now })
2635
+ .where(eq(schema.documentPropertyDefinitions.id, existing.id));
2636
+ } else {
2637
+ const [maxPos] = await db
2638
+ .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
2639
+ .from(schema.documentPropertyDefinitions)
2640
+ .where(
2641
+ eq(schema.documentPropertyDefinitions.databaseId, args.database.id),
2642
+ );
2643
+ propertyId = crypto.randomUUID();
2644
+ await db.insert(schema.documentPropertyDefinitions).values({
2645
+ id: propertyId,
2646
+ ownerEmail: args.database.ownerEmail,
2647
+ orgId: args.database.orgId,
2648
+ databaseId: args.database.id,
2649
+ name: SOURCE_PROPERTY_NAME,
2650
+ type: "select",
2651
+ visibility: "always_show",
2652
+ optionsJson,
2653
+ position: (maxPos?.max ?? -1) + 1,
2654
+ createdAt: args.now,
2655
+ updatedAt: args.now,
2656
+ });
2657
+ }
2658
+
2659
+ // A row's Source value IS its owning source id (= the option id); unsourced
2660
+ // rows get the "Local" sentinel. Pure id mapping, no source-name hop.
2661
+ const rows = await db
2662
+ .select({
2663
+ documentId: schema.contentDatabaseSourceRows.documentId,
2664
+ sourceId: schema.contentDatabaseSourceRows.sourceId,
2665
+ })
2666
+ .from(schema.contentDatabaseSourceRows)
2667
+ .where(
2668
+ inArray(
2669
+ schema.contentDatabaseSourceRows.sourceId,
2670
+ sources.map((source) => source.id),
2671
+ ),
2672
+ );
2673
+ const ownerSourceIdByDocumentId = new Map<string, string>();
2674
+ for (const row of rows) {
2675
+ if (row.documentId)
2676
+ ownerSourceIdByDocumentId.set(row.documentId, row.sourceId);
2677
+ }
2678
+
2679
+ const items = await db
2680
+ .select({ documentId: schema.contentDatabaseItems.documentId })
2681
+ .from(schema.contentDatabaseItems)
2682
+ .where(eq(schema.contentDatabaseItems.databaseId, args.database.id));
2683
+ for (const item of items) {
2684
+ const optionId =
2685
+ ownerSourceIdByDocumentId.get(item.documentId) ?? SOURCE_LOCAL_OPTION_ID;
2686
+ const valueJson = serializePropertyValue(optionId);
2687
+ const [existingValue] = await db
2688
+ .select({ id: schema.documentPropertyValues.id })
2689
+ .from(schema.documentPropertyValues)
2690
+ .where(
2691
+ and(
2692
+ eq(schema.documentPropertyValues.documentId, item.documentId),
2693
+ eq(schema.documentPropertyValues.propertyId, propertyId),
2694
+ ),
2695
+ );
2696
+ if (existingValue) {
2697
+ await db
2698
+ .update(schema.documentPropertyValues)
2699
+ .set({ valueJson, updatedAt: args.now })
2700
+ .where(eq(schema.documentPropertyValues.id, existingValue.id));
2701
+ } else {
2702
+ await db.insert(schema.documentPropertyValues).values({
2703
+ id: crypto.randomUUID(),
2704
+ ownerEmail: args.database.ownerEmail,
2705
+ documentId: item.documentId,
2706
+ propertyId,
2707
+ valueJson,
2708
+ createdAt: args.now,
2709
+ updatedAt: args.now,
2710
+ });
2711
+ }
2712
+ }
2713
+ }
2714
+
2066
2715
  export async function getSourceRows(sourceId: string) {
2067
2716
  const db = getDb();
2068
2717
  return db