Multiple Datasets in an Object Type
Overview
An object type can be backed by multiple datasets simultaneously. Each dataset contributes rows that become objects and columns that become properties. All datasets are treated equally When a new dataset is linked, its rows are synchronized into the object type:
- If a row's primary key already exists as an object, the object is enriched with data from the new dataset.
- If the primary key is new, a new object is created.
Prerequisites
Before linking a second (or subsequent) dataset to an object type, the following must be configured:
| Requirement | Why |
|---|---|
primary_key_column set on the object type | The sync engine needs to know which column identifies each object uniquely. Without this, sync is skipped entirely. |
| Primary key mapping on the new dataset | The user must select which column in the new dataset corresponds to the object type's primary key. This is enforced via MapPrimaryKeyModal — the dataset cannot be linked without completing this step. |
title_column is not required. If a dataset does not have the title column, objects are created using the primary key value as their name.
Linking Flow
User clicks "Add dataset"
│
▼
FileBrowserModal opens
(already-linked datasets appear disabled)
│
▼
User selects a dataset
│
▼
MapPrimaryKeyModal opens
(user must select which column maps to the PK)
│
├── User confirms with a column selected
│ │
│ ▼
│ Dataset is linked with key_column mapping
│ Sync runs immediately
│
└── User closes/cancels
│
▼
Operation cancelled — dataset is NOT linked
Duplicate Prevention
A dataset that is already linked to the object type cannot be linked again. This is enforced at three levels:
- Visual: the dataset appears disabled in
FileBrowserModalwith the label "Already linked" - Selection:
selectItemrejects items whoseid,source_id, or metadatadatasetIdmatches an already-linked dataset - Guard:
handleBrowseSelectverifies against the currentdatasetsarray before proceeding
Sync Engine
The sync engine (app/api/model/objects/sync.ts) runs immediately after a dataset is linked. It processes up to 10,000 rows in batches of 200.
Algorithm
1. Load all existing objects for this object type into a map:
primary_key → { id, name, source_dataset_ids, property_overrides }
2. For each row in the new dataset:
a. Read the value of dsKeyCol (the mapped key column)
b. Skip if null or empty
c. Truncate to 200 characters
d. If primary_key EXISTS in the map:
→ ENRICH the existing object:
- Merge row columns into property_overrides
- Add datasetId to source_dataset_ids (if not already present)
- Update name from title_column only if current name equals the PK
e. If primary_key does NOT EXIST:
→ CREATE a new object:
- name = title_column value if available, otherwise PK value
- source_dataset_ids = [datasetId]
- Add to in-memory map to prevent duplicate creation within same batch
3. Batch insert new objects (upsert with onConflict: object_type_id, user_id, primary_key)
4. Batch update existing objects (parallel, 50 at a time)
5. Update object_count on the object type
6. Notify Neo4j sync if any objects were created or updated
Key Column Resolution
| Scenario | Key column used |
|---|---|
| First dataset (no prior datasets) | primary_key_column from object type |
| Subsequent dataset with explicit mapping | The column selected in MapPrimaryKeyModal |
| Subsequent dataset without mapping | Falls back to primary_key_column (but this path is blocked by UI — the modal is mandatory) |
Column Mapping (Properties)
After linking, the dataset's columns appear in the Column Mapping section. Columns are not automatically mapped to properties — the user must explicitly map them (individually or via "Automap all" in the context menu).
Property Exclusivity Rule
Each property (identified by display_name) can only be backed by one dataset. The primary key column is the only exception.
| Scenario | Result |
|---|---|
Dataset A has column email mapped to property "Email" | Allowed |
Dataset B also has column email, user tries to map it to "Email" | Blocked — conflict message shown |
Dataset B maps its email column to a different property name (e.g., "Contact Email") | Allowed |
Both datasets map their customer_id column as the PK | Allowed — PK is exempt |
When a conflict exists:
- The "Map as property" button is replaced by an italic message: Property "X" is already backed by [dataset name]
- The toggle switch is dimmed and disabled
- "Automap all" silently skips conflicting columns
Mapping creates properties, not objects
Mapping a column to a property defines the schema — it tells the system which columns to expose as object properties. The actual object creation/enrichment happens during sync (at link time), not during column mapping.
Dataset Context Menu
Each dataset in the Column Mapping section has a three-dot menu (rendered as a portal to avoid clipping) with two options:
| Action | Description |
|---|---|
| Automap all | Maps all unmapped columns as properties (skips conflicts). Only shown if unmapped columns exist. |
| Remove datasource | Unlinks the dataset from the object type with persistence (DELETE on object_type_datasets). |
Data Model
object_type_datasets table
| Column | Type | Description |
|---|---|---|
object_type_id | uuid | FK to object_types |
dataset_id | uuid | FK to datasets (resolved from project_file or direct) |
role | text | Always 'backing' (legacy column, no longer distinguishes primary/enrichment) |
key_column | text | The column in this dataset that maps to the object type's PK. null for the first dataset. |
dataset_name | text | Display name |
source_service | text | Origin service identifier |
priority | int | Reserved for future ordering |
Unique constraint: (object_type_id, dataset_id) — prevents duplicate links.
objects table (relevant fields)
| Column | Type | Description |
|---|---|---|
primary_key | text | The unique identifier across all backing datasets |
name | text | Display name (from title_column or PK fallback) |
source_dataset_ids | text[] | Array of all dataset IDs that contribute to this object |
property_overrides | jsonb | Merged column values from all datasets (keyed by column name) |
dataset_row_id | uuid | Row ID from the creating dataset |
Unique constraint: (object_type_id, user_id, primary_key) — ensures one object per PK per user per type.