diff --git a/02 - Data Models/Category.md b/02 - Data Models/Category.md index db89d28..c56e5cd 100644 --- a/02 - Data Models/Category.md +++ b/02 - Data Models/Category.md @@ -1,6 +1,6 @@ --- title: Category -tags: [data-model, mongoose] +tags: [data-model, mongoose, postgres] aliases: [Category Model, Taxonomy, ICategory] --- @@ -10,14 +10,16 @@ Hierarchical taxonomy node used by [[PurchaseRequest]] and [[RequestTemplate]]. > [!note] Source > `backend/src/models/Category.ts:15` — schema definition -> `backend/src/models/Category.ts:60` — model export +> `backend/src/models/Category.ts:64` — model export +> `backend/src/services/marketplace/categoryStore.ts:89` — Postgres runtime bootstrap and duplicate cleanup +> `backend/src/db/schema/category.ts:88` — Drizzle active normalized-name unique index ## Schema | Field | Type | Required | Default | Validation | Index | Description | | --- | --- | --- | --- | --- | --- | --- | | `name` | String | yes | — | trim | yes | Local language name. | -| `nameEn` | String | yes | — | trim | yes | English name. | +| `nameEn` | String | yes | — | trim | unique | English name. | | `description` | String | no | — | trim | — | Description. | | `icon` | String | no | — | trim | — | Icon identifier / URL. | | `isActive` | Boolean | no | `true` | — | yes | Active flag. | @@ -32,13 +34,19 @@ None defined. ## Indexes -Defined at `backend/src/models/Category.ts:55-58`: +Defined at `backend/src/models/Category.ts:55-62`: - `{ name: 1 }` -- `{ nameEn: 1 }` +- `{ nameEn: 1 }`, unique - `{ isActive: 1 }` - `{ parentId: 1 }` +Postgres runtime and Drizzle additionally enforce: + +- `categories_legacy_object_id_uq`: unique Mongo bridge id for idempotent backfill/upsert. +- `categories_active_name_norm_uq`: unique active category display label using `lower(btrim(name)) WHERE is_active = true`. +- Existing duplicate active PG rows are deactivated before the unique index is created; purchase-request category references and child category parents are repointed to the kept row. + ## Pre/Post Hooks None declared. @@ -70,7 +78,7 @@ stateDiagram-v2 ## Common Queries ```ts -// Top-level categories +// Top-level categories; runtime store dedupes active rows by normalized display name. Category.find({ parentId: null, isActive: true }).sort({ order: 1 }); // Children of a category diff --git a/02 - Data Models/MongoDB to PostgreSQL Migration Guide.md b/02 - Data Models/MongoDB to PostgreSQL Migration Guide.md index 3eab604..8a58c54 100644 --- a/02 - Data Models/MongoDB to PostgreSQL Migration Guide.md +++ b/02 - Data Models/MongoDB to PostgreSQL Migration Guide.md @@ -4,7 +4,7 @@ tags: [data-model, migration, mongodb, postgres, mongoose, helper] aliases: [Mongo to Postgres, DB Migration Guide, Postgres Migration] created: 2026-05-31 source: backend/src (automated multi-agent scan) -updated: 2026-06-01 for backend integrate-main-into-development@6df113d +updated: 2026-06-01 for backend integrate-main-into-development@1543b53 --- # MongoDB → PostgreSQL Migration Guide @@ -17,7 +17,7 @@ updated: 2026-06-01 for backend integrate-main-into-development@6df113d > **Execution plan:** see the companion [[MongoDB to PostgreSQL Migration Plan (Drizzle)]] for the phased, Drizzle-concrete plan (repository seam, `id_map`, dual-write, per-phase cutover runbook). > [!warning] Current implementation delta -> This guide started as a migration helper. Backend `integrate-main-into-development@6df113d` now contains the first Postgres implementation layer: Drizzle schemas/migrations through `0008`, `src/db/client.ts`, `id_map`, `pg_dualwrite_gaps`, repository implementations/factory, backfill/verify scripts, conditional `payment_quotes` persistence, and aligned purchase/template request budget validation. Backend `2.8.13` also hardens the PurchaseRequest/SellerOffer backfill runner for marketplace-core dry-runs and selected-offer remapping. The broad service layer is still Mongoose-first and is **not** fully wired through those repositories. Use [[Postgres Runtime Cutover Status]] as the authoritative current-state snapshot. +> This guide started as a migration helper. Backend `integrate-main-into-development@1543b53` now contains the first Postgres implementation layer: Drizzle schemas/migrations through `0009`, `src/db/client.ts`, `id_map`, `pg_dualwrite_gaps`, repository implementations/factory, backfill/verify scripts, conditional `payment_quotes` persistence, and aligned purchase/template request budget validation. Backend `2.8.17` also hardens the PurchaseRequest/SellerOffer backfill runner for marketplace-core dry-runs and selected-offer remapping, and enforces unique active marketplace categories by normalized visible name in Postgres mode. The broad service layer is still Mongoose-first and is **not** fully wired through those repositories. Use [[Postgres Runtime Cutover Status]] as the authoritative current-state snapshot. > [!info] Scan coverage (2026-05-31) > - **23** Mongoose models (collections) @@ -564,8 +564,9 @@ Hierarchical category system for classifying purchase requests and request templ #### Indexes -- `idx_categories_name` (UNIQUE): optimizes name lookups +- `idx_categories_name` (UNIQUE in Drizzle baseline): optimizes name lookups - `idx_categories_name_en`: optimizes English name lookups +- `categories_active_name_norm_uq` (UNIQUE, partial): enforces one active category per `lower(btrim(name))` - `idx_categories_is_active`: heavily used in filtered queries (active categories only) - `idx_categories_parent_id`: supports tree-building and subcategory queries @@ -576,6 +577,7 @@ Hierarchical category system for classifying purchase requests and request templ 3. **Timestamps immutability**: Mongoose auto-sets createdAt once; ensure PG constraints prevent manual updates to createdAt. 4. **Cache invalidation**: CategoryService invalidates Redis cache on every mutation; ensure application layer continues to manage this post-migration. 5. **Dual localization**: name (local language) and nameEn (English) are always paired; enforce as a unit in API validation. +6. **Duplicate active labels**: visible category names must be unique after trimming/case-folding. Migration `0009_unique_active_categories.sql` deactivates duplicate active rows and repoints dependent category references before creating the partial unique index. #### Migration Strategy @@ -584,7 +586,8 @@ Hierarchical category system for classifying purchase requests and request templ 3. Add composite index on (order, name) to match Mongoose sort patterns: `.sort({ order: 1, name: 1 })`. 4. Migrate all ObjectId values from MongoDB to UUIDs using a UUID generation function or application-layer conversion. 5. Ensure dependent tables (purchase_requests, request_templates) update their category_id FKs to point to new PG uuid PKs. -6. Validate tree structure: no cycles, all parentId refs are valid or NULL. +6. Deactivate duplicate active rows by normalized `name`, and repoint purchase request/category parent references to the kept row before adding `categories_active_name_norm_uq`. +7. Validate tree structure: no cycles, all parentId refs are valid or NULL. #### Proposed DDL @@ -604,6 +607,9 @@ CREATE TABLE categories ( CREATE UNIQUE INDEX idx_categories_name ON categories(name); CREATE INDEX idx_categories_name_en ON categories(name_en); +CREATE UNIQUE INDEX categories_active_name_norm_uq + ON categories (lower(btrim(name))) + WHERE is_active = true; CREATE INDEX idx_categories_is_active ON categories(is_active); CREATE INDEX idx_categories_parent_id ON categories(parent_id); CREATE INDEX idx_categories_order_name ON categories("order", name); @@ -4385,8 +4391,9 @@ CREATE TRIGGER tg_notification_metadata_validate | | `order` | B-tree index | `CREATE INDEX idx_levelconfig_order` | Display order | | | `isActive` | B-tree index | `CREATE INDEX idx_levelconfig_isactive` | Active level filter | | **ShopSettings** | `sellerId` | UNIQUE | `UNIQUE CONSTRAINT` | One shop per seller | -| **Category** | `name` | B-tree index | `CREATE INDEX idx_category_name` | Name lookup | +| **Category** | `name` | UNIQUE B-tree index | `CREATE UNIQUE INDEX idx_category_name` | Name lookup | | | `nameEn` | B-tree index | `CREATE INDEX idx_category_name_en` | English name lookup | +| | `lower(btrim(name)) WHERE is_active = true` | Partial UNIQUE expression index | `CREATE UNIQUE INDEX categories_active_name_norm_uq` | Prevent duplicate active visible labels | | | `isActive` | B-tree index | `CREATE INDEX idx_category_isactive` | Active category filter | | | `parentId` | B-tree index | `CREATE INDEX idx_category_parentid` | Hierarchy traversal | | **SellerOffer** | `sellerId` | B-tree index | `CREATE INDEX idx_selleroffer_seller` | Seller's offers | @@ -4722,7 +4729,7 @@ Each phase has explicit entry/exit criteria. The unit of migration is a bounded #### Phase 2 — Reference/config data - Category (self-referential FK, soft-delete), LevelConfig, ConfigSetting, ConfigSettingHistory, ShopSettings, Review. Mostly scalar, low write volume, few invariants. Builds the seed-in-dependency-order path. -- **Entry:** Phase 1 exit. **Exit:** these read from PG; seeds run in PG; FK/unique constraints (e.g., `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL) enforced. +- **Entry:** Phase 1 exit. **Exit:** these read from PG; seeds run in PG; FK/unique constraints (e.g., `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL, Category active normalized-name uniqueness) enforced. #### Phase 3 — User + auth core - User is the FK hub. Normalize nested `profile`/`preferences`/`points`/`referralStats` and extract the `passkeys` and `refreshTokens` arrays to child tables. Reimplement `toJSON()` password stripping and passkey `default: Date.now()` in app code. Sparse+unique email index → partial unique index `WHERE email IS NOT NULL`. diff --git a/02 - Data Models/MongoDB to PostgreSQL Migration Plan (Drizzle).md b/02 - Data Models/MongoDB to PostgreSQL Migration Plan (Drizzle).md index 8e86920..14d7a6c 100644 --- a/02 - Data Models/MongoDB to PostgreSQL Migration Plan (Drizzle).md +++ b/02 - Data Models/MongoDB to PostgreSQL Migration Plan (Drizzle).md @@ -4,7 +4,7 @@ tags: [data-model, migration, postgres, drizzle, plan, runbook] aliases: [Drizzle Migration Plan, PG Migration Plan] created: 2026-05-31 companion: "[[MongoDB to PostgreSQL Migration Guide]]" -updated: 2026-06-01 for backend integrate-main-into-development@6df113d backend 2.8.13 +updated: 2026-06-01 for backend integrate-main-into-development@1543b53 backend 2.8.17 --- # MongoDB → PostgreSQL Migration Plan (Drizzle) @@ -17,7 +17,7 @@ updated: 2026-06-01 for backend integrate-main-into-development@6df113d backend > **Scope reminder:** partial migration (Phases 0–5) is the recommended stopping point — ≈16–28 engineer-weeks. Full migration of Chat/Notification/sessions is explicitly deferred. > [!warning] Current implementation status -> Backend `2.8.13` has started the runtime cutover with store-specific raw Postgres facades: auth-owned users/Telegram auth records behind `AUTH_STORE=postgres`, confirmation-threshold config/history behind `CONFIG_STORE=postgres`, user address CRUD behind `ADDRESS_STORE=postgres`, and the first marketplace/reference domains behind `CATEGORY_STORE=postgres`, `LEVEL_CONFIG_STORE=postgres`, `SHOP_SETTINGS_STORE=postgres`, and `REVIEW_STORE=postgres`. It also contains the broader `src/db/` Drizzle schemas, repository implementations/factory, id-map bridge, and backfill runner described below, but the broad marketplace/payment/points services are still mostly not wired through that factory. Mongo remains authoritative unless a per-store flag is explicitly flipped. See [[Postgres Runtime Cutover Status]]. +> Backend `2.8.17` has started the runtime cutover with store-specific raw Postgres facades: auth-owned users/Telegram auth records behind `AUTH_STORE=postgres`, confirmation-threshold config/history behind `CONFIG_STORE=postgres`, user address CRUD behind `ADDRESS_STORE=postgres`, and the first marketplace/reference domains behind `CATEGORY_STORE=postgres`, `LEVEL_CONFIG_STORE=postgres`, `SHOP_SETTINGS_STORE=postgres`, and `REVIEW_STORE=postgres`. Category PG mode now deactivates duplicate active names and enforces an active normalized-name unique index. It also contains the broader `src/db/` Drizzle schemas, repository implementations/factory, id-map bridge, and backfill runner described below, but the broad marketplace/payment/points services are still mostly not wired through that factory. Mongo remains authoritative unless a per-store flag is explicitly flipped. See [[Postgres Runtime Cutover Status]]. --- @@ -270,10 +270,14 @@ export const categories = pgTable('categories', { }, (t) => ({ parentIdx: index('categories_parent_idx').on(t.parentId), activeIdx: index('categories_active_idx').on(t.isActive), + activeNameNormUq: uniqueIndex('categories_active_name_norm_uq') + .on(sql`lower(btrim(${t.name}))`) + .where(sql`${t.isActive} = true`), })); // relations(): parentId → categories.id, ON DELETE SET NULL ``` `Category.parentId` is itself Mixed (ObjectId | string) in the model — verify all rows are ObjectIds during the pre-migration audit; treat stray strings as data errors to clean. +Active categories must also be unique by normalized visible name; migration `0009_unique_active_categories.sql` deactivates duplicate active rows and repoints category references before adding the unique index. ### 4.4 Sparse-unique → partial unique index — `User.email`, `User.referralCode` The runtime code in `connection.ts` rebuilds `users.email` as unique+sparse. In PG: @@ -348,8 +352,8 @@ Same phases as the guide §2, here with Drizzle-concrete entry/exit gates. Each ### Phase 2 — Reference/config (2–3 wk) - `Category` (self-FK, soft-delete), `LevelConfig`, `ConfigSetting`, `ConfigSettingHistory`, `ShopSettings`, `Review`. -- **Status 2026-06-01:** confirmation-threshold `ConfigSetting` / `ConfigSettingHistory`, categories, level config, shop settings, and reviews have opt-in PG runtime paths through their per-store flags; writes mirror back to Mongo where still-Mongo consumers need compatibility. -- Port seeds to run in dependency order. Enforce `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL. +- **Status 2026-06-01:** confirmation-threshold `ConfigSetting` / `ConfigSettingHistory`, categories, level config, shop settings, and reviews have opt-in PG runtime paths through their per-store flags; writes mirror back to Mongo where still-Mongo consumers need compatibility. Categories now enforce one active row per normalized visible name in PG mode. +- Port seeds to run in dependency order. Enforce `ShopSettings.sellerId` unique, Category `parentId` ON DELETE SET NULL, and Category active normalized-name uniqueness. - **Exit:** these read from PG; seeds run in PG. ### Phase 3 — User + auth core (3–5 wk) @@ -361,7 +365,7 @@ Same phases as the guide §2, here with Drizzle-concrete entry/exit gates. Each ### Phase 4 — Money core (6–10 wk) — *the point of the project* - `PurchaseRequest`, `SellerOffer`, `Payment`, `FundsLedgerEntry`, `DerivedDestination`, `TrezorAccount`, `PointTransaction`. -- **Status 2026-06-01:** Drizzle schemas/repositories and backfill scripts exist for PurchaseRequest/SellerOffer. Backend `2.8.13` hardens the marketplace-core backfill path with `npm run backfill:marketplace-core:postgres`, fixed PurchaseRequest timestamp/preferred-seller writes, and a post-SellerOffer selected-offer remap step. Runtime marketplace services still call Mongoose directly and must not be flipped with `REPO_MARKETPLACE` until service wiring plus shadow-read checks land. +- **Status 2026-06-01:** Drizzle schemas/repositories and backfill scripts exist for PurchaseRequest/SellerOffer. Backend `2.8.17` hardens the marketplace-core backfill path with `npm run backfill:marketplace-core:postgres`, fixed PurchaseRequest timestamp/preferred-seller writes, a post-SellerOffer selected-offer remap step, and category duplicate cleanup/unique active-name enforcement. Runtime marketplace services still call Mongoose directly and must not be flipped with `REPO_MARKETPLACE` until service wiring plus shadow-read checks land. - Apply §4.1 (Mixed→discriminator+FK), §4.2 (offers/preferredSellers junctions, deliveryInfo/serviceInfo child tables), §4.5 (derivation counter). - **Wrap in real PG transactions the multi-doc writes that today have none:** `raiseDispute` (PurchaseRequest + Payment), payment confirm + `FundsLedgerEntry` AML-fee insert, referral reward (points + referralStats), PointsService flows (migrate its 2 `withTransaction` sites to PG `BEGIN/COMMIT`). - Preserve the `Payment` partial-unique idempotency index and `FundsLedgerEntry.idempotencyKey` uniqueness. diff --git a/02 - Data Models/Postgres Runtime Cutover Status.md b/02 - Data Models/Postgres Runtime Cutover Status.md index e9bc17c..69ea514 100644 --- a/02 - Data Models/Postgres Runtime Cutover Status.md +++ b/02 - Data Models/Postgres Runtime Cutover Status.md @@ -3,14 +3,14 @@ title: Postgres Runtime Cutover Status tags: [data-model, postgres, migration, runtime-status] aliases: [Postgres Status, PG Cutover Status, Mongo vs Postgres Runtime] created: 2026-05-31 -source: backend integrate-main-into-development@6df113d +source: backend integrate-main-into-development@1543b53 --- # Postgres Runtime Cutover Status -> **Current branch:** backend `integrate-main-into-development` at `6df113d`, version `2.8.13`. +> **Current branch:** backend `integrate-main-into-development` at `1543b53`, version `2.8.17`. > -> **Bottom line:** this branch is **Postgres-capable**, not fully Postgres-backed. Auth-owned user data can run through Postgres when `AUTH_STORE=postgres`; confirmation-threshold runtime config/history can run through Postgres when `CONFIG_STORE=postgres`; user address CRUD can run through Postgres when `ADDRESS_STORE=postgres`; marketplace categories, level config, shop settings, and reviews can run through Postgres with their own store flags. All PG-backed stores require `PG_URL`. Mongo remains the default and the compatibility store for still-Mongo domains. +> **Bottom line:** this branch is **Postgres-capable**, not fully Postgres-backed. Auth-owned user data can run through Postgres when `AUTH_STORE=postgres`; confirmation-threshold runtime config/history can run through Postgres when `CONFIG_STORE=postgres`; user address CRUD can run through Postgres when `ADDRESS_STORE=postgres`; marketplace categories, level config, shop settings, and reviews can run through Postgres with their own store flags. The category PG path now enforces one active visible category per normalized name. All PG-backed stores require `PG_URL`. Mongo remains the default and the compatibility store for still-Mongo domains. ## What Uses Postgres Now @@ -22,7 +22,7 @@ source: backend integrate-main-into-development@6df113d | Auth-owned user store | Opt-in with `AUTH_STORE=postgres` | Auth, passkey, Telegram auth/link/session/temp-verification, and `/api/user` profile paths use an auth-store facade. In PG mode, users are stored in Postgres and mirrored back to Mongo through `legacy_object_id` for compatibility with still-Mongo services. | | Confirmation-threshold runtime config | Opt-in with `CONFIG_STORE=postgres` | `ConfigSetting` / `ConfigSettingHistory` access for `/api/admin/settings/confirmation-thresholds` and transaction-safety confirmation thresholds uses a config-store facade. PG-mode writes mirror back to Mongo for rollback. | | User addresses | Opt-in with `ADDRESS_STORE=postgres` | `/api/addresses` CRUD uses an address-store facade. PG mode enforces one primary address per user with a partial unique index and mirrors writes/deletes back to Mongo for rollback. | -| Marketplace categories | Opt-in with `CATEGORY_STORE=postgres` | `CategoryService` and the default `General` category path use a category-store facade. PG-mode writes mirror back to Mongo for rollback and still-Mongo request/template references. | +| Marketplace categories | Opt-in with `CATEGORY_STORE=postgres` | `CategoryService` and the default `General` category path use a category-store facade. PG-mode writes mirror back to Mongo for rollback and still-Mongo request/template references. PG schema bootstrap/migration deactivates duplicate active category labels, repoints existing category references to the kept row, and enforces `categories_active_name_norm_uq` on `lower(btrim(name)) WHERE is_active = true`. List/cache reads also dedupe by normalized name. | | Level configuration | Opt-in with `LEVEL_CONFIG_STORE=postgres` | `PointsService` level reads use a level-config facade. `PointTransaction` and user points remain Mongo-backed. | | Shop settings | Opt-in with `SHOP_SETTINGS_STORE=postgres` | Shop settings controller, seller payment rail resolution, and review enable/disable checks use a shop-settings facade. PG-mode writes mirror back to Mongo. | | Marketplace reviews | Opt-in with `REVIEW_STORE=postgres` | Review list/summary/create routes use a review-store facade. PG-mode list responses still hydrate `reviewerId` from the user mirror to preserve frontend shape. | @@ -56,7 +56,7 @@ Most of the service layer still imports Mongoose models directly. Auth-owned pat | `AUTH_STORE` | `mongo` by default. Set `AUTH_STORE=postgres` to route auth-owned users, refresh tokens, passkeys, Telegram links/sessions, and temp verifications through Postgres. | | `CONFIG_STORE` | `mongo` by default. Set `CONFIG_STORE=postgres` to route confirmation-threshold settings/history through Postgres. | | `ADDRESS_STORE` | `mongo` by default. Set `ADDRESS_STORE=postgres` to route `/api/addresses` through Postgres. | -| `CATEGORY_STORE` | `mongo` by default. Set `CATEGORY_STORE=postgres` to route marketplace category reads/writes through Postgres. | +| `CATEGORY_STORE` | `mongo` by default. Set `CATEGORY_STORE=postgres` to route marketplace category reads/writes through Postgres. Active PG categories are unique by normalized visible name. | | `LEVEL_CONFIG_STORE` | `mongo` by default. Set `LEVEL_CONFIG_STORE=postgres` to route level configuration reads and seed replacement through Postgres. `LEVEL_STORE=postgres` is accepted as a compatibility alias. | | `SHOP_SETTINGS_STORE` | `mongo` by default. Set `SHOP_SETTINGS_STORE=postgres` to route shop settings, review gates, and seller payment rails through Postgres. | | `REVIEW_STORE` | `mongo` by default. Set `REVIEW_STORE=postgres` to route marketplace reviews through Postgres. | @@ -76,7 +76,7 @@ Most of the service layer still imports Mongoose models directly. Auth-owned pat - `PG_URL=... npm run backfill:level-config:postgres` - `PG_URL=... npm run backfill:shop-settings:postgres` - `PG_URL=... npm run backfill:review:postgres` -6. Run `PG_URL=... MONGODB_URI=... scripts/smoke/reference-stores-postgres.sh`, then set `CATEGORY_STORE=postgres LEVEL_CONFIG_STORE=postgres SHOP_SETTINGS_STORE=postgres REVIEW_STORE=postgres` together in non-prod. +6. Run `PG_URL=... scripts/smoke/categories-postgres-unique.sh` and `PG_URL=... MONGODB_URI=... scripts/smoke/reference-stores-postgres.sh`, then set `CATEGORY_STORE=postgres LEVEL_CONFIG_STORE=postgres SHOP_SETTINGS_STORE=postgres REVIEW_STORE=postgres` together in non-prod. 7. For marketplace-core data, run `MIGRATION_MONGO_URL=... MIGRATION_PG_URL=... npm run backfill:marketplace-core:postgres:dry-run`, then the non-dry `npm run backfill:marketplace-core:postgres` against non-prod. The group runs root dependencies, PurchaseRequest main rows, SellerOffer rows, then the selected-offer remap. 8. Run `scripts/smoke/marketplace-core-postgres-backfill.sh` with the same migration DSNs and record row-count/checksum results. 9. Wire remaining services to repository interfaces one domain at a time. diff --git a/09 - Audits/Activity Log.md b/09 - Audits/Activity Log.md index 2de2d62..b4ac333 100644 --- a/09 - Audits/Activity Log.md +++ b/09 - Audits/Activity Log.md @@ -11,6 +11,18 @@ entries on top. Maintained by agents per the rule in `../AGENTS.md`. --- +### 2026-06-01 — backend@1543b53, frontend@457de07 — enforce unique active categories + +**Commits:** backend `1543b53`, frontend `457de07` (backend `2.8.17`, frontend `2.8.17`) +**Touched:** +- Backend: `src/services/marketplace/categoryStore.ts`, `src/services/marketplace/CategoryService.ts`, `src/db/schema/category.ts`, `src/db/migrations/0009_unique_active_categories.sql`, `src/db/migrations/meta/_journal.json`, `__tests__/category-store.test.ts`, `scripts/smoke/categories-postgres-unique.sh`, `scripts/smoke/reference-stores-postgres.sh`, `package.json`, `package-lock.json` +- Frontend: `package.json` version metadata only. +**Why:** Category seed/backfill reruns could leave multiple active rows with the same visible label, which surfaced in the category dropdown as repeated Persian names. The PG category store now deactivates duplicate active rows before adding a normalized active-name unique index, repoints existing category references to the kept row, catches duplicate inserts as idempotent creates, and dedupes cached/list responses so stale rows cannot leak into the UI. +**Verification:** Backend `npm test -- --runTestsByPath __tests__/category-store.test.ts __tests__/postgres-client.test.ts`; backend `npm run typecheck`; backend `npm run build:server`; backend `PG_URL=postgresql://escrow:throwaway@127.0.0.1:5434/escrow_migration_test ./scripts/smoke/categories-postgres-unique.sh`; frontend `npx tsc --noEmit --project tsconfig.json`; frontend `npm run build` (passed with the existing non-fatal SSR `getPosts` fetch refusal during static page generation). +**Linked docs updated:** [[Category]], [[Postgres Runtime Cutover Status]], [[MongoDB to PostgreSQL Migration Plan (Drizzle)]], [[MongoDB to PostgreSQL Migration Guide]] + +--- + ### 2026-06-01 — backend@6df113d, frontend@0f1db64 — harden marketplace-core Postgres backfill **Commits:** backend `6df113d`, frontend `0f1db64` (backend `2.8.13`, frontend `2.8.13`)