Skip to content

ENG-1907 - Update content uniqueness and upsert surfaces#1198

Merged
maparent merged 4 commits into
mainfrom
eng-1907-update-content-uniqueness-and-upsert-surfaces
Jul 20, 2026
Merged

ENG-1907 - Update content uniqueness and upsert surfaces#1198
maparent merged 4 commits into
mainfrom
eng-1907-update-content-uniqueness-and-upsert-surfaces

Conversation

@mdroidian

@mdroidian mdroidian commented Jul 6, 2026

Copy link
Copy Markdown
Member

Updated content identity so multiple representations of the same source item can coexist. Content is now unique on (space_id, source_local_id, variant, content_type), and upsert_content uses that same key so, for example, a full markdown row and a full ATJSON row do not overwrite each other.

FileReference now includes content_type so asset references point to the exact parent Content representation. The current default is text/obsidian+markdown for compatibility with the existing Obsidian asset sync path; future callers should pass the parent content type explicitly.

Also updated the embedding view/types and added a feature scenario covering two full content rows with different content types.

Also fixes ENG-1908 because changing Content uniqueness to include content_type makes the old FileReference foreign key ambiguous. Updating FileReference to include content_type is part of making ENG-1907’s new content identity valid.


Open in Devin Review

…riants

- Introduced a new scenario in the feature file to test coexistence of different content types.
- Updated step definitions to include checks for content rows based on variant and content type.
- Modified database types to include `content_type` in relevant tables and relationships.
- Adjusted SQL schemas to enforce content type constraints and ensure proper indexing.
- Backfilled existing rows with default content types to maintain data integrity.
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

ENG-1907

ENG-1908

@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
discourse-graph Ready Ready Preview, Comment Jul 20, 2026 1:03pm

Request Review

@supabase

supabase Bot commented Jul 6, 2026

Copy link
Copy Markdown

Updates to Preview Branch (eng-1907-update-content-uniqueness-and-upsert-surfaces) ↗︎

Deployments Status Updated
Database Mon, 20 Jul 2026 13:01:40 UTC
Services Mon, 20 Jul 2026 13:01:40 UTC
APIs Mon, 20 Jul 2026 13:01:40 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Mon, 20 Jul 2026 13:01:40 UTC
Migrations Mon, 20 Jul 2026 13:01:40 UTC
Seeding Mon, 20 Jul 2026 13:01:40 UTC
Edge Functions Mon, 20 Jul 2026 13:01:43 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

@mdroidian

Copy link
Copy Markdown
Member Author

@codex review

filehash character varying NOT NULL, -- or binary?
"created" timestamp without time zone NOT NULL,
last_modified timestamp without time zone NOT NULL,
content_type character varying NOT NULL DEFAULT 'text/obsidian+markdown',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting FileReference.content_type to text/obsidian+markdown preserves current behavior because Obsidian is the only file-reference writer today and does not pass this value yet. This is not a hard modeling choice: longer term, callers should probably pass the parent Content.content_type explicitly, e.g. Roam would use text/roam+markdown. Happy to make the DB stricter now if we prefer requiring that on insert.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e21302e23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@graphite-app

This comment was marked as resolved.

@mdroidian
mdroidian requested a review from maparent July 6, 2026 06:34
@mdroidian

Copy link
Copy Markdown
Member Author

One concern with this PR: PR #1154 added Content.content_type with a table default of text/plain and backfilled existing full rows as markdown. This PR adds a function-level fallback that turns omitted content_type into text/plain before the new (space_id, source_local_id, variant, content_type) conflict key is evaluated.

That means any omitted content_type upsert for an existing markdown full row could insert a second full/text/plain row instead of updating the existing markdown row. Current Obsidian content upserts are safe because they pass content_type explicitly. Roam has a full writer that omits it, but it only runs behind includeFullContent, which does not appear to be enabled anywhere today.

My preference is to make content_type required for content upserts so this cannot silently happen. We could either do that in this PR or make it an explicit follow-up before enabling more full writers. That would mean updating all callers/examples to pass content_type explicitly.

maparent commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

TBH I'm not sure that's a good idea as is, and I remember deciding against it, and I thought it had been discussed. If we do this, we will have multiple formats of a content, and we won't know which one is the original/source of truth. First, what is your use case of having multiple formats? I can see an optimization to avoid repeating translation work. In that case, what I would suggest if you think the time saving is worth the storage cost (which is plausible but I think we should analyze a bit), is to add another converted_from column, saying that a given content row was converted from another content row. Then we would know which one is the source of truth, and we could still have a unique key for the original triple (without format) when the converted_from column is empty. Or am I missing a use case?

Comment on lines +16 to +18
CREATE UNIQUE INDEX IF NOT EXISTS content_space_local_id_variant_content_type_originals_idx ON public."Content" USING btree (
space_id, source_local_id, variant, original
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unique index conflicts with the PR's goal. The index on (space_id, source_local_id, variant, original) prevents multiple Content rows with the same (space_id, source_local_id, variant) when original=true. However, the PR explicitly allows multiple representations (e.g., markdown and ATJSON) to coexist with the same variant='full' as shown in the test scenario (lines 137-184 of addContent.feature). Since Content.original defaults to true (line 93 of content.sql), this index will cause a unique constraint violation when inserting the second content row in the test.

-- This index should be removed or original should be nullable
-- and only set to true for one canonical representation
CREATE UNIQUE INDEX IF NOT EXISTS content_space_local_id_variant_content_type_originals_idx 
  ON public."Content" USING btree (space_id, source_local_id, variant, original);

The test expects 2 Content rows with variant='full' and different content_types, but this will fail because both will have original=true by default, violating this unique constraint.

Suggested change
CREATE UNIQUE INDEX IF NOT EXISTS content_space_local_id_variant_content_type_originals_idx ON public."Content" USING btree (
space_id, source_local_id, variant, original
);
CREATE UNIQUE INDEX IF NOT EXISTS content_space_local_id_variant_content_type_originals_idx ON public."Content" USING btree (
space_id, source_local_id, variant, content_type
);

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@maparent
maparent force-pushed the eng-1907-update-content-uniqueness-and-upsert-surfaces branch from d10befd to 5b1ca3e Compare July 16, 2026 14:06
Add an original column to Content.
Add a full index including original to Content;
To allow multiple distinct format translations, set original to NULL instead of FALSE.
Adjust the ForeignKey from FileReference to Content to use that index, instead of adding content_type to ForeignKey.
That last change is worth discussing.
@maparent
maparent merged commit 8fafdc9 into main Jul 20, 2026
9 of 10 checks passed
@maparent
maparent deleted the eng-1907-update-content-uniqueness-and-upsert-surfaces branch July 20, 2026 13:02
Comment on lines +200 to +210
ON CONFLICT (space_id, source_local_id, variant, content_type) DO UPDATE SET
document_id = COALESCE(db_content.document_id, EXCLUDED.document_id),
author_id = COALESCE(db_content.author_id, EXCLUDED.author_id),
creator_id = COALESCE(db_content.creator_id, EXCLUDED.creator_id),
created = COALESCE(db_content.created, EXCLUDED.created),
text = COALESCE(db_content.text, EXCLUDED.text),
metadata = COALESCE(db_content.metadata, EXCLUDED.metadata),
scale = COALESCE(db_content.scale, EXCLUDED.scale),
last_modified = COALESCE(db_content.last_modified, EXCLUDED.last_modified),
part_of_id = COALESCE(db_content.part_of_id, EXCLUDED.part_of_id),
original = db_content.original

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical bug in upsert logic: The ON CONFLICT clause uses (space_id, source_local_id, variant, content_type) but unconditionally updates original field on conflict. This allows overwriting an original=true content row with original=NULL (when original: false is passed), which breaks the foreign key constraint from FileReference that requires original=true.

Example failure:

-- Insert original content
INSERT INTO Content (..., original) VALUES (..., true);
-- FileReference now points to this row

-- Upsert same content with original=false
-- This updates original to NULL, breaking FileReference!
INSERT INTO Content (..., original) VALUES (..., NULL)
ON CONFLICT (..., content_type) DO UPDATE SET original = NULL;

Fix: Either exclude original from the UPDATE clause, or add validation to prevent changing original from true to NULL:

ON CONFLICT (space_id, source_local_id, variant, content_type) DO UPDATE SET
    ...,
    -- Don't allow overwriting original=true with NULL
    original = CASE 
        WHEN EXCLUDED.original IS NULL THEN Content.original 
        ELSE EXCLUDED.original 
    END
Suggested change
ON CONFLICT (space_id, source_local_id, variant, content_type) DO UPDATE SET
document_id = COALESCE(db_content.document_id, EXCLUDED.document_id),
author_id = COALESCE(db_content.author_id, EXCLUDED.author_id),
creator_id = COALESCE(db_content.creator_id, EXCLUDED.creator_id),
created = COALESCE(db_content.created, EXCLUDED.created),
text = COALESCE(db_content.text, EXCLUDED.text),
metadata = COALESCE(db_content.metadata, EXCLUDED.metadata),
scale = COALESCE(db_content.scale, EXCLUDED.scale),
last_modified = COALESCE(db_content.last_modified, EXCLUDED.last_modified),
part_of_id = COALESCE(db_content.part_of_id, EXCLUDED.part_of_id),
original = db_content.original
ON CONFLICT (space_id, source_local_id, variant, content_type) DO UPDATE SET
document_id = COALESCE(db_content.document_id, EXCLUDED.document_id),
author_id = COALESCE(db_content.author_id, EXCLUDED.author_id),
creator_id = COALESCE(db_content.creator_id, EXCLUDED.creator_id),
created = COALESCE(db_content.created, EXCLUDED.created),
text = COALESCE(db_content.text, EXCLUDED.text),
metadata = COALESCE(db_content.metadata, EXCLUDED.metadata),
scale = COALESCE(db_content.scale, EXCLUDED.scale),
last_modified = COALESCE(db_content.last_modified, EXCLUDED.last_modified),
part_of_id = COALESCE(db_content.part_of_id, EXCLUDED.part_of_id),
original = CASE
WHEN EXCLUDED.original IS NULL THEN db_content.original
ELSE EXCLUDED.original
END

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants