Skip to content

schema generator: emit UNIQUE NULLS NOT DISTINCT on PG for indexes derived from nullable conflict columns #1260

@willgriffin

Description

@willgriffin

Context

#1246 surfaced a class of bugs where `conflictColumns` that include nullable columns (e.g. `tenant_id` on `@TenantScoped({ mode: 'optional' })` models) silently fail to match under `ON CONFLICT(...)` because SQL treats NULL as distinct. The smrt-core fix at commit 82d7618 is a tactical workaround in `planPersistenceWrite()`. The proper fix has two upstream layers:

  1. `@happyvertical/sql` handles NULL-aware upsert transparently → tracked at happyvertical/sdk#1025
  2. smrt-core's schema generator (this issue) emits PG-native `UNIQUE NULLS NOT DISTINCT` indexes when conflict columns are nullable, so the trap doesn't exist at all on PG 15+.

What to build

In `packages/core/src/schema/generator.ts`, when generating the UNIQUE index for a model's `conflictColumns`:

  • Inspect each conflict column's field decorator to see if it's nullable
  • If ANY conflict column is nullable AND the target dialect is Postgres 15+: emit `CREATE UNIQUE INDEX ... ON ... USING btree (...) NULLS NOT DISTINCT`
  • If older PG: fall back to a partial unique index OR rely on the `@happyvertical/sql` query-time fix (sdk#1025) — document the choice in the generator
  • If SQLite: no schema-level fix exists (SQLite has no `NULLS NOT DISTINCT`); rely on the sql-layer fix from sdk#1025

The dialect detection should already exist in the schema generator (PG / SQLite paths diverge for other reasons).

Why this matters

With the schema-level fix, the SMRT framework's upsert behavior on PG becomes self-consistent: ON CONFLICT works as users intuitively expect, no application-layer probe needed. Pairs naturally with the sdk#1025 fix for non-PG / older-PG cases.

Acceptance criteria

  • PG 15+ schema generator emits `UNIQUE NULLS NOT DISTINCT` for indexes derived from nullable conflict columns
  • PG < 15 documented behavior (partial index, or rely on sdk#1025)
  • SQLite generator unchanged (no schema-level fix available)
  • Test coverage in `packages/core/tests/schema/` for the new generator behavior
  • Existing test suite stays green — schema migrations on existing tables are non-destructive (the migration tool either tolerates the index rebuild or the new attribute is additive)
  • CLAUDE.md update describing the canonical "optional tenancy + per-tenant unique natural key" pattern and that the framework handles NULL semantics for it

Blocked by

None — this can land independently of sdk#1025. They're complementary; this fixes PG at the schema layer, sdk#1025 fixes everywhere at the query layer.

Follow-up

Once both this issue AND sdk#1025 land, the tactical workaround in `packages/core/src/object.ts` (commit 82d7618, the `planPersistenceWrite` NULL probe) can be removed. Tracked as a separate issue.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions