@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/create.ts +1 -0
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
- package/corpus/core/src/sharing/registry.ts +17 -0
- package/corpus/core/src/templates/default/pnpm-workspace.yaml +1 -0
- package/corpus/core/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
- package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +34 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
- package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
- package/corpus/templates/content/AGENTS.md +45 -44
- package/corpus/templates/content/README.md +16 -0
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +125 -0
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +28 -6
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +238 -69
- package/corpus/templates/content/actions/_builder-cms-write-settings.ts +125 -43
- package/corpus/templates/content/actions/_database-source-utils.ts +746 -97
- package/corpus/templates/content/actions/_database-utils.ts +17 -14
- package/corpus/templates/content/actions/_federation-join.ts +14 -2
- package/corpus/templates/content/actions/_join-suggestion.ts +89 -14
- package/corpus/templates/content/actions/_local-file-documents.ts +2 -1
- package/corpus/templates/content/actions/attach-content-database-source.ts +124 -5
- package/corpus/templates/content/actions/bind-content-database-source-field.ts +256 -0
- package/corpus/templates/content/actions/change-content-database-source-role.ts +420 -0
- package/corpus/templates/content/actions/create-document.ts +8 -0
- package/corpus/templates/content/actions/execute-builder-source-batch.ts +282 -0
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +220 -8
- package/corpus/templates/content/actions/get-content-database.ts +10 -2
- package/corpus/templates/content/actions/prepare-builder-source-execution.ts +19 -2
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +47 -15
- package/corpus/templates/content/actions/refresh-content-database-source.ts +11 -4
- package/corpus/templates/content/actions/review-content-database-source-change-set.ts +6 -2
- package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +32 -12
- package/corpus/templates/content/actions/stage-builder-revision.ts +14 -10
- package/corpus/templates/content/actions/validate-builder-source-execution.ts +25 -3
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1520 -553
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +150 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +647 -66
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +543 -204
- package/corpus/templates/content/app/hooks/use-content-database.ts +62 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +64 -0
- package/corpus/templates/content/app/i18n-data.ts +784 -0
- package/corpus/templates/content/changelog/2026-06-29-builder-review-checks-now-preserve-publish-and-unpublish-cho.md +6 -0
- package/corpus/templates/content/changelog/2026-06-29-database-sources-can-now-be-added-as-more-rows-or-matched.md +6 -0
- package/corpus/templates/content/changelog/2026-06-29-fixed-loading-the-final-rows-in-larger-databases-without-bre.md +6 -0
- package/corpus/templates/content/shared/api.ts +91 -1
- package/corpus/templates/content/vite.config.ts +14 -7
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +1 -0
- package/dist/cli/create.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +34 -3
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +7 -7
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/sharing/registry.d.ts.map +1 -1
- package/dist/sharing/registry.js +11 -0
- package/dist/sharing/registry.js.map +1 -1
- package/dist/templates/default/pnpm-workspace.yaml +1 -0
- package/dist/templates/workspace-root/pnpm-workspace.yaml +1 -0
- package/package.json +1 -1
- package/src/templates/default/pnpm-workspace.yaml +1 -0
- 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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 (
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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:
|
|
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
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
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
|