Skip to content

feat(schema): field constraint improvements — unique, required, indexed, validation - #1512

Closed
Glacier-Luo wants to merge 20 commits into
emdash-cms:mainfrom
Glacier-Luo:feat/field-constraints
Closed

feat(schema): field constraint improvements — unique, required, indexed, validation#1512
Glacier-Luo wants to merge 20 commits into
emdash-cms:mainfrom
Glacier-Luo:feat/field-constraints

Conversation

@Glacier-Luo

Copy link
Copy Markdown
Contributor

What does this PR do?

Strengthens field constraints across the schema layer, fixing several gaps where constraints were declared but not enforced at the database level.

Unique constraints

  • Enforce unique field values with a real database index (partial, per-locale, excluding soft-deleted rows)
  • Pre-check uniqueness on content update, including draft revision JSON
  • Handle duplicate and restore operations: append "(Copy)" or generate a fresh slug to avoid constraint violations
  • Prevent backfill conflicts when making a unique field required (check for existing rows with the fill value)

Required field sync

  • When a field is toggled to required, backfill existing NULL values with a type-appropriate default and add a NOT NULL column constraint
  • When toggled back, drop the NOT NULL constraint
  • Use parameterized values (not string interpolation) in backfill queries

Indexed fields (new feature)

  • Add an indexed boolean option to custom fields
  • Creates/drops a B-tree index on the field's column (idx_{table}_{column})
  • Exposed in the field editor UI, seed definitions, CLI schema export, and MCP tools
  • Migration 044 adds the indexed column to _emdash_fields

Validation

  • Validate minItems/maxItems for repeater and multiSelect fields at runtime (Zod schema generation)
  • Add min/max bounds to the admin field editor number inputs (prevent negative minItems or zero maxItems)

Reserved field slugs

  • Expand RESERVED_FIELD_SLUGS to cover all internal columns (locale, translation_group, live_revision_id, draft_revision_id, primary_byline_id, scheduled_at, deleted_at, version, loc_upd, loc_crt)

Orphan cleanup

  • Clean up orphaned field data in JSON data column when content is permanently deleted or a collection is dropped
  • Add missing error toasts for content create and field mutation failures in the admin UI

Type of change

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.6 via Claude Code

Screenshots / test output

Test coverage added for: unique constraint enforcement, required sync backfill, indexed field creation/removal, duplicate/restore with unique fields, orphan cleanup on hard-delete, minItems/maxItems validation, and reserved slug rejection.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c492c6b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jun 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

See CONTRIBUTING.md for the full contribution policy.

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,155 lines across 29 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
packages/admin/src/locales/zh-CN/messages.po Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1512

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1512

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1512

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1512

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1512

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1512

emdash

npm i https://pkg.pr.new/emdash@1512

create-emdash

npm i https://pkg.pr.new/create-emdash@1512

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1512

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1512

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1512

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1512

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1512

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1512

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1512

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1512

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1512

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1512

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1512

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1512

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1512

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1512

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1512

commit: c492c6b

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 25, 2026
Adds `locale`, `translation_group`, and 9 index-collision slugs
(e.g. `author`, `scheduled`) to RESERVED_FIELD_SLUGS. Prevents
ADD COLUMN conflicts and index name collisions with built-in indexes
created by createContentTable().
Creates a partial unique index (scoped by locale, excluding soft-deleted
rows) when a field has unique=true. Validates type compatibility (rejects
JSON/image/file) and the non-translatable+unique contradiction.

Handles unique constraint changes on updateField (with duplicate-value
pre-check), cleans up indexes on deleteField before DROP COLUMN.

Adds unique-aware conflict detection to content restore and unique-field
clearing on content duplicate to prevent constraint violations.
…ations

Adds onError handlers to createMutation (ContentNewPage),
addFieldMutation, updateFieldMutation, and deleteFieldMutation so API
errors surface as toast notifications instead of being silently swallowed.

Fixes the floating Promise bug in ContentTypeEditor.handleFieldSave by
awaiting onAddField/onUpdateField — on error the dialog stays open for
retry instead of closing immediately.

Adds cross-status unique constraint tests confirming the DB index
enforces uniqueness regardless of content status (draft vs published).
Extracts unique field logic into api/handlers/unique-check.ts and adds
a pre-check in emdash-runtime before draft revision or content table
writes. Checks both content table columns and draft revision JSON data
to catch published and draft-vs-draft conflicts.
Backfill NULL rows with type-appropriate defaults when a field changes
from optional to required; on Postgres also SET/DROP NOT NULL.
Adds a declarative `indexed` boolean to custom fields. When enabled,
a B-tree index is created on the content table column to accelerate
WHERE/ORDER BY queries. Non-indexable types (JSON, image, file,
repeater) are rejected at the API layer. Unique supersedes plain
indexed — when both are set, only the unique index is created.

Includes migration 044, Admin UI toggle, CLI/MCP/seed passthrough,
and seed `translatable` passthrough fix.
…untime

Adds a repeater case to getBaseSchema (was falling through to
z.unknown()) and a ZodArray branch to applyValidation so minItems
and maxItems constraints are enforced for both repeater and
multiSelect fields.
…tion delete

Content hard-delete now also removes content_taxonomies and
_emdash_content_bylines rows. Collection delete now cleans up
revisions, content_taxonomies, _emdash_comments, _emdash_seo,
and _emdash_content_bylines before dropping the content table.
Expose min/max selection constraints in the field editor for multiSelect
fields, matching the existing repeater minItems/maxItems UI. The Zod
runtime validation already supported this; this commit adds the admin UI
surface with localized labels (Min Selections / Max Selections).
These slugs collide with locale composite index names created by
migration 041 (idx_{table}_loc_upd, idx_{table}_loc_crt).
Replace sql.raw(fillValue) with Kysely parameterized interpolation in
the UPDATE statement. Adds toColumnValue() and getEmptyColumnValue()
that return JS values instead of SQL literals, eliminating the injection
surface from formatDefaultValue's manual quote-escaping.
…tent

Add missing indexed field to the Zod response schema. Guard migration
044 with columnExists check so it can be safely re-run. Add 044 to the
trailing migrations test list.
Add tests verifying that _emdash_seo, _emdash_comments, and revisions
rows are cleaned up when content is permanently deleted. These three
tables were cleaned up by the orphan data commit but lacked test
coverage.
- checkDuplicateValues: replace HAVING cnt with HAVING COUNT(*) > 1
  (Postgres does not support SELECT alias in HAVING)
- checkUniqueFieldConflicts: use dialect-aware json_extract/->>'path'
  instead of SQLite-only json_extract()
- syncRequiredConstraint: fall back to empty default when toColumnValue
  returns null (explicit null defaultValue edge case)
- Migration 044 down(): guard with columnExists for idempotency
- Add 12 tests for checkUniqueFieldConflicts covering content-table
  conflicts, draft-revision conflicts, self-exclusion, soft-delete
  exclusion, and locale scoping
- duplicate(): keep required non-string unique values instead of
  deleting them (which caused NOT NULL violations for integer/number)
- restore(): add validateIdentifier() before sql.ref(field.slug) for
  defense-in-depth consistency
- Fix false-positive locale-scope test that used wrong collection slug
  and never exercised the locale filtering logic
When a unique field changes from optional to required, the NULL backfill
would set all NULL rows to the same default value, violating the unique
index. Add a pre-check that rejects the operation when multiple NULLs
exist in the same locale.

Also add behavioral tests for required=true unique index enforcement and
duplicate-allowed verification after unique→plain index switch.
- Rebuild unique index predicate when only `required` changes on a unique field
- Prevent double "(Copy)" suffix when title/name is also a unique field
- Move validateIdentifier before tableName construction in unique-check
- Add tests for index predicate rebuild and duplicate dedup
- Add min={0} on Min Selections/Min Items to prevent negative values
- Add min={1} on Max Selections/Max Items to prevent zero upper bound
- Format migration 044 (oxfmt, no logic change)
@Glacier-Luo
Glacier-Luo force-pushed the feat/field-constraints branch from 131a50a to c492c6b Compare June 29, 2026 08:53
@Glacier-Luo

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main and resolved migration numbering conflict (044 → 045). All tests pass — ready for review when you get a chance. @ascorbic

@ascorbic

Copy link
Copy Markdown
Collaborator

Hi @Glacier-Luo
There's a lot of good stuff here, but it combines too many separate pieces into one PR. Could you split it out into separate ones per change. The bug fixes (cleanup on permanent delete, minItems/maxItems validation, error toasts, reserved slug expansion) could land right awayas separate PRs.

The indexed fields part is a new feature4 and should have a discussion first.

There are also some translation changes that have slipped into this PR and should land separately too.

@Glacier-Luo

Copy link
Copy Markdown
Contributor Author

Hi @Glacier-Luo There's a lot of good stuff here, but it combines too many separate pieces into one PR. Could you split it out into separate ones per change. The bug fixes (cleanup on permanent delete, minItems/maxItems validation, error toasts, reserved slug expansion) could land right awayas separate PRs.

The indexed fields part is a new feature4 and should have a discussion first.

There are also some translation changes that have slipped into this PR and should land separately too.

OK, i'll do that.:)

@github-actions github-actions Bot mentioned this pull request Jun 29, 2026
18 tasks
@ascorbic

Copy link
Copy Markdown
Collaborator

Closing per my comment. To be replaced with individual PRs

@ascorbic ascorbic closed this Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core bot:review Trigger an emdashbot code review on this PR cla: signed needs-rebase overlap review/needs-review No maintainer or bot review yet size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants