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:
- `@happyvertical/sql` handles NULL-aware upsert transparently → tracked at happyvertical/sdk#1025
- 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
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.
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:
What to build
In `packages/core/src/schema/generator.ts`, when generating the UNIQUE index for a model's `conflictColumns`:
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
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.