28 KiB
Research: K-1 Normalized Data Model — Technical Questions
Feature Branch: 006-k1-model-review
Date: 2026-03-20
Prisma Version: 6.19.0
PostgreSQL: 16
NestJS: 11+ (@nestjs/event-emitter 3.0.1 already installed, EventEmitterModule imported in app.module.ts)
1. Prisma @@map and COMMENT ON Annotations (FR-012)
Decision
Use raw SQL statements appended to the Prisma-generated migration file. After running prisma migrate dev --create-only to generate the structural DDL, manually append COMMENT ON TABLE and COMMENT ON COLUMN statements to the same .sql migration file before applying it.
Rationale
Prisma has no native support for PostgreSQL comments. There is no @comment attribute, no @@comment model attribute, and @@map/@map only control table/column name mapping — not metadata comments. Three options exist:
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| (a) Raw SQL in migration file | Single source of truth; comments ship with the migration; version-controlled; reviewed in PR | Must manually append after prisma migrate dev --create-only; must maintain when schema changes |
Chosen |
| (b) Post-migration script | Can be automated via package.json hook |
Runs outside the migration transaction; easy to forget; comments drift from schema | Rejected |
| (c) Prisma client extension | Could add comments at runtime | Extensions operate at the client query layer, not DDL; cannot emit COMMENT ON; wrong abstraction level |
Rejected |
Why (a) wins: The project already uses prisma migrate dev for all schema changes (see 95+ existing migrations in prisma/migrations/). The existing migration at 20260316120000_added_family_office_tables/migration.sql is pure SQL — appending COMMENT ON statements is idiomatic. Comments are part of the schema, so they belong in the migration.
Prisma's @@map and @map are relevant but solve a different problem: they let Prisma model names (PascalCase) map to PostgreSQL table/column names (snake_case). The current schema does not use @@map — Prisma uses the model name directly as the table name (e.g., model KDocument → table KDocument). The spec's FR-012 requires snake_case table names, so we'll need @@map in addition to COMMENT ON.
Code Example
Step 1: Prisma model with @@map / @map for snake_case table/column names:
model K1BoxDefinition {
boxKey String @id @map("box_key")
label String
section String?
dataType String @default("number") @map("data_type")
sortOrder Int @map("sort_order")
irsFormLine String? @map("irs_form_line")
description String?
isCustom Boolean @default(false) @map("is_custom")
isIgnored Boolean @default(false) @map("is_ignored")
partnershipId String? @map("partnership_id")
partnership Partnership? @relation(fields: [partnershipId], onDelete: Cascade, references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lineItems K1LineItem[]
@@map("k1_box_definition")
@@unique([partnershipId, boxKey])
@@index([partnershipId])
}
Step 2: After prisma migrate dev --create-only, append to the generated .sql file:
-- COMMENT ON annotations for LLM discoverability (FR-012)
COMMENT ON TABLE "k1_box_definition" IS 'Reference table of IRS Schedule K-1 (Form 1065) box definitions. Maps box identifiers to human-readable labels, sections, and data types. Global rows (partnership_id IS NULL) define IRS defaults. Per-partnership rows override display settings.';
COMMENT ON COLUMN "k1_box_definition"."box_key" IS 'IRS K-1 box identifier: "1" for ordinary income, "9a" for long-term capital gains, "20-A" for other information code A.';
COMMENT ON COLUMN "k1_box_definition"."label" IS 'Human-readable label for this box, e.g. "Ordinary business income (loss)".';
COMMENT ON COLUMN "k1_box_definition"."section" IS 'IRS form section: HEADER, PART_I, PART_II, SECTION_J, SECTION_K, SECTION_L, PART_III.';
COMMENT ON COLUMN "k1_box_definition"."data_type" IS 'Expected data type: number, string, percentage, or boolean.';
COMMENT ON COLUMN "k1_box_definition"."is_custom" IS 'True if this box was auto-created during import for a key not in the IRS standard set.';
COMMENT ON COLUMN "k1_box_definition"."partnership_id" IS 'NULL for global IRS defaults. Non-null for per-partnership display overrides (custom label, isIgnored).';
COMMENT ON TABLE "k1_line_item" IS 'Individual financial line item from an IRS Schedule K-1 (Form 1065). One row per box per K-1 document. Fact table in a star schema with KDocument and K1BoxDefinition as dimensions.';
COMMENT ON COLUMN "k1_line_item"."box_key" IS 'FK to k1_box_definition. IRS K-1 box identifier.';
COMMENT ON COLUMN "k1_line_item"."amount" IS 'Dollar amount reported on this line item, DECIMAL(15,2). Negative = loss. NULL when value is non-numeric (see text_value).';
COMMENT ON COLUMN "k1_line_item"."text_value" IS 'Non-numeric value such as "SEE STMT" or "X" (checkbox). Present when amount is NULL.';
COMMENT ON COLUMN "k1_line_item"."is_superseded" IS 'True if this row was replaced by a newer version (e.g., ESTIMATED → FINAL K-1 transition). Aggregation queries filter WHERE is_superseded = false.';
COMMENT ON COLUMN "k1_line_item"."confidence" IS 'OCR extraction confidence score, 0.00–1.00. NULL if manually entered.';
Workflow: Run npx prisma migrate dev --create-only --name add_k1_tables, then hand-edit the .sql to append comments, then run npx prisma migrate dev to apply. This is the pattern recommended by the Prisma team for any DDL that Prisma doesn't generate natively.
2. Prisma Materialized Views (FR-010, FR-011)
Decision
Raw SQL in a Prisma migration file to create the materialized views + prisma.$executeRawUnsafe() in a NestJS service to refresh them, triggered by @OnEvent('k-document.changed').
Do NOT use Prisma's view preview feature — it only supports regular CREATE VIEW, not CREATE MATERIALIZED VIEW, and is still in preview as of Prisma 6.19.0.
Rationale
| Option | Supports Materialized? | Refresh Mechanism | Prisma Type Safety | Verdict |
|---|---|---|---|---|
(a) Raw SQL migration + $queryRaw / $executeRawUnsafe |
Yes | REFRESH MATERIALIZED VIEW CONCURRENTLY via service |
Query results need manual typing via $queryRaw<Type> |
Chosen |
(b) Prisma view preview feature |
No — only CREATE VIEW |
N/A (regular views auto-refresh) | Yes, generates types | Rejected for materialized |
(c) db.execute in a service (no migration) |
Yes | Same as (a) | Same as (a) | Rejected — DDL should be version-controlled in migrations |
Key details:
-
Prisma
viewpreview feature (enabled viapreviewFeatures = ["views"]in generator block) lets you declareviewinstead ofmodelinschema.prisma. Prisma then generates read-only types. However, it only handlesCREATE VIEW— there is no syntax forMATERIALIZED. The Prisma team's GitHub issue prisma/prisma#17335 tracks materialized view support — still open as of 2026-03. Usingviewfor a materialized view will causeprisma migrate devto emitCREATE VIEW, which is wrong. -
REFRESH MATERIALIZED VIEW CONCURRENTLYrequires aUNIQUE INDEXon the materialized view. This must be included in the migration. -
@nestjs/event-emitteris already installed (v3.0.1) andEventEmitterModuleis already imported inapp.module.ts(line 24). The infrastructure for@OnEventis ready. -
Query results from materialized views can be read via
prisma.$queryRaw<T>with a manually defined TypeScript interface, or via a Prismaviewmodel (which works for reads even if the underlying object is actually a materialized view — Prisma doesn't check). The latter gives better DX but is a slight hack.
Code Example
Migration file (YYYYMMDD_create_k1_materialized_views/migration.sql):
-- Materialized View 1: K-1 Summary by Partnership/Year
CREATE MATERIALIZED VIEW mv_k1_partnership_year_summary AS
SELECT
kd."partnershipId" AS partnership_id,
kd."taxYear" AS tax_year,
li."boxKey" AS box_key,
bd."label",
bd."section",
SUM(li."amount") AS total_amount,
COUNT(*) AS line_count
FROM "K1LineItem" li
JOIN "KDocument" kd ON li."kDocumentId" = kd."id"
JOIN "K1BoxDefinition" bd ON li."boxKey" = bd."boxKey" AND bd."partnershipId" IS NULL
WHERE li."isSuperseded" = false
GROUP BY kd."partnershipId", kd."taxYear", li."boxKey", bd."label", bd."section"
WITH NO DATA;
-- Required for CONCURRENTLY refresh
CREATE UNIQUE INDEX idx_mv_k1_pys_unique
ON mv_k1_partnership_year_summary (partnership_id, tax_year, box_key);
-- Initial population
REFRESH MATERIALIZED VIEW mv_k1_partnership_year_summary;
Note on column names: If
@@mapis adopted (Question 1), the table and column names in the view SQL will use the mapped snake_case names instead of PascalCase. Adjust accordingly.
NestJS service (k1-materialized-view.service.ts):
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class K1MaterializedViewService {
private readonly logger = new Logger(K1MaterializedViewService.name);
constructor(private readonly prismaService: PrismaService) {}
@OnEvent('k-document.changed')
async handleKDocumentChanged() {
this.logger.log('Refreshing K-1 materialized views...');
await this.refreshAll();
}
async refreshAll() {
await this.prismaService.$executeRawUnsafe(
`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_k1_partnership_year_summary`
);
// Add additional MVs here as they are created
}
async getPartnershipYearSummary(partnershipId: string, taxYear: number) {
return this.prismaService.$queryRaw<
Array<{
partnership_id: string;
tax_year: number;
box_key: string;
label: string;
section: string;
total_amount: number;
line_count: number;
}>
>`
SELECT * FROM mv_k1_partnership_year_summary
WHERE partnership_id = ${partnershipId}
AND tax_year = ${taxYear}
ORDER BY box_key
`;
}
}
Emitting the event (in K1ImportService.confirm() or KDocument update logic):
import { EventEmitter2 } from '@nestjs/event-emitter';
// After KDocument create/update:
this.eventEmitter.emit('k-document.changed', { kDocumentId, partnershipId });
Optional hybrid: You can declare view mv_k1_partnership_year_summary in schema.prisma to get Prisma-generated types for reads. Prisma will try to CREATE VIEW on the next migration — just manually delete that migration SQL and keep the materialized view migration. This is fragile; the $queryRaw<T> approach with manual interfaces is more honest.
3. K1BoxDefinition Composite Key with partnershipId (FR-015)
Decision
Option (c): Split into two models — K1BoxDefinition (global IRS reference, PK = boxKey) and K1BoxOverride (per-partnership display overrides, FK to K1BoxDefinition). K1LineItem's FK points to the global K1BoxDefinition only.
Rationale
The core problem: K1BoxDefinition serves two purposes:
- Referential integrity — K1LineItem.boxKey must be a valid IRS box identifier
- Display customization — Per-partnership overrides for labels, ignored status, custom entries
Mixing both roles in one table with partnershipId = null for globals and partnershipId = <uuid> for overrides creates an FK ambiguity:
| Option | FK Target | Problem |
|---|---|---|
| (a) K1LineItem FK → global rows only | boxKey WHERE partnershipId IS NULL |
Prisma cannot express a filtered FK. You'd need a compound FK (boxKey, partnershipId) with partnershipId always null in K1LineItem — awkward. Also, custom per-partnership boxes (not in global set) can't be FK targets. |
(b) Composite FK (boxKey, partnershipId) |
Exact row | K1LineItem would need a partnershipId column duplicating KDocument.partnershipId. Denormalization. Also, every K1LineItem for a partnership without overrides would point to the global row, requiring a COALESCE-style lookup at insert time ("does an override exist? if not, FK to global"). Complex insert logic. |
| (c) Split into two tables | K1BoxDefinition.boxKey (simple FK) |
Clean separation. Global IRS reference is the FK target. Per-partnership display overrides are a separate concern queried at render time, not at data-insert time. Custom boxes added to the global table with isCustom = true. |
Why (c) is cleanest in Prisma:
- Simple
StringPK on K1BoxDefinition (boxKey) - Simple FK on K1LineItem (
boxKey→K1BoxDefinition.boxKey) - No composite FKs, no nullable FK components, no filtered relations
- Per-partnership overrides are a JOIN-at-read concern, not a data-integrity concern
- Prisma
@relationworks naturally with single-column FKs
How per-partnership custom boxes work: When a partnership has a custom box (e.g., "11-ZZ*" created during import per FR-017), a global K1BoxDefinition row is created with isCustom = true. It's globally unique by boxKey. If another partnership also has "11-ZZ*", they share the same K1BoxDefinition row (the label may differ via K1BoxOverride). This avoids duplicate boxKey entries.
What about the spec saying "Per-partnership overrides become rows with a non-null partnershipId"? That works for the override table (K1BoxOverride), not the reference table (K1BoxDefinition). The spec's intent (replace CellMapping) is preserved: CellMapping's global rows → K1BoxDefinition, CellMapping's per-partnership rows → K1BoxOverride.
Code Example
/// Global IRS K-1 box reference. One row per unique box identifier.
/// PK is the box key string (e.g., "1", "9a", "20-A").
model K1BoxDefinition {
boxKey String @id @map("box_key")
label String
section String?
dataType String @default("number") @map("data_type")
sortOrder Int @map("sort_order")
irsFormLine String? @map("irs_form_line")
description String?
isCustom Boolean @default(false) @map("is_custom")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lineItems K1LineItem[]
overrides K1BoxOverride[]
@@map("k1_box_definition")
@@index([section])
@@index([sortOrder])
}
/// Per-partnership display overrides for a K1BoxDefinition.
/// Controls custom labels, ignored status, etc. Does NOT affect data integrity.
model K1BoxOverride {
id String @id @default(uuid())
boxKey String @map("box_key")
boxDefinition K1BoxDefinition @relation(fields: [boxKey], references: [boxKey], onDelete: Cascade)
partnershipId String @map("partnership_id")
partnership Partnership @relation(fields: [partnershipId], onDelete: Cascade, references: [id])
customLabel String? @map("custom_label")
isIgnored Boolean @default(false) @map("is_ignored")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([boxKey, partnershipId])
@@map("k1_box_override")
@@index([partnershipId])
}
model K1LineItem {
id String @id @default(uuid())
kDocumentId String @map("k_document_id")
kDocument KDocument @relation(fields: [kDocumentId], onDelete: Cascade, references: [id])
boxKey String @map("box_key")
boxDefinition K1BoxDefinition @relation(fields: [boxKey], references: [boxKey])
amount Decimal? @db.Decimal(15, 2)
textValue String? @map("text_value")
rawText String? @map("raw_text")
confidence Decimal? @db.Decimal(3, 2)
sourcePage Int? @map("source_page")
sourceCoords Json? @map("source_coords")
isUserEdited Boolean @default(false) @map("is_user_edited")
isSuperseded Boolean @default(false) @map("is_superseded")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([kDocumentId, boxKey, isSuperseded]) // See Question 5
@@map("k1_line_item")
@@index([kDocumentId])
@@index([boxKey])
@@index([isSuperseded])
}
CellMapping → K1BoxDefinition + K1BoxOverride migration:
| CellMapping field | → K1BoxDefinition | → K1BoxOverride |
|---|---|---|
boxNumber |
boxKey (PK) |
boxKey (FK) |
label |
label (IRS default) |
customLabel (override) |
description |
description |
— |
cellType |
dataType |
— |
sortOrder |
sortOrder |
— |
isCustom |
isCustom |
— |
isIgnored |
— | isIgnored |
partnershipId (null) |
(global row) | — |
partnershipId (non-null) |
— | partnershipId |
CellAggregationRule: Update its model to reference K1BoxDefinition boxKeys in its sourceCells JSON array. No structural change needed — the string values in sourceCells are already box key strings like ["1", "8", "9a"].
4. Prisma JSON Field Backfill Migration (FR-006, FR-017)
Decision
Use a single SQL INSERT ... SELECT statement with jsonb_each() inside the Prisma migration .sql file. Handle non-numeric values (text, booleans) with PostgreSQL CASE / jsonb_typeof() expressions. Auto-create missing K1BoxDefinition rows in a preceding CTE.
Rationale
Prisma migration files are plain .sql files executed against PostgreSQL. There is no limitation on SQL complexity. PostgreSQL's jsonb_each() function is the standard way to iterate JSONB keys.
| Approach | Pros | Cons | Verdict |
|---|---|---|---|
Single SQL with jsonb_each() |
Atomic, fast, runs in migration transaction, no application code needed | Complex SQL; harder to debug | Chosen |
TypeScript migration script (prisma.$executeRaw in a seed file) |
Easier to debug; can log per-row | Runs outside migration system; not version-controlled as migration; slower (row-by-row) | Rejected |
| Multi-step: SQL to temp table then INSERT | Intermediate visibility | Unnecessary complexity for <1000 documents | Rejected |
Handling value types:
JSON value type (jsonb_typeof()) |
K1LineItem.amount | K1LineItem.text_value | Example |
|---|---|---|---|
'number' |
(value)::decimal |
NULL |
"1": 50000 |
'string' |
NULL |
value #>> '{}' |
"11": "SEE STMT" |
'boolean' |
NULL |
CASE WHEN value = 'true' THEN 'true' ELSE 'false' END |
"FINAL_K1": true |
Auto-creating missing K1BoxDefinition rows (FR-017): A CTE first collects all distinct JSON keys across all KDocuments, then inserts any keys not already in K1BoxDefinition with isCustom = true. A second CTE (or the existing CellMapping data) provides labels.
Code Example
Migration file (YYYYMMDD_backfill_k1_line_items/migration.sql):
-- Step 1: Auto-create K1BoxDefinition rows for any JSON keys not already defined.
-- Uses CellMapping label if available, otherwise raw key as label. (FR-017)
INSERT INTO "k1_box_definition" ("box_key", "label", "data_type", "sort_order", "is_custom", "created_at", "updated_at")
SELECT DISTINCT
je.key,
COALESCE(cm."label", je.key),
CASE jsonb_typeof(je.value)
WHEN 'number' THEN 'number'
WHEN 'boolean' THEN 'boolean'
ELSE 'string'
END,
9999, -- high sort order for custom entries
true,
NOW(),
NOW()
FROM "KDocument" kd,
jsonb_each(kd."data"::jsonb) AS je(key, value)
LEFT JOIN "CellMapping" cm ON cm."boxNumber" = je.key AND cm."partnershipId" IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM "k1_box_definition" bd WHERE bd."box_key" = je.key
)
ON CONFLICT ("box_key") DO NOTHING;
-- Step 2: Backfill K1LineItem rows from KDocument.data JSON blobs.
-- One row per JSON key per KDocument.
INSERT INTO "k1_line_item" (
"id",
"k_document_id",
"box_key",
"amount",
"text_value",
"raw_text",
"is_user_edited",
"is_superseded",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid(),
kd."id",
je.key,
-- amount: numeric values only
CASE
WHEN jsonb_typeof(je.value) = 'number' THEN (je.value)::decimal
ELSE NULL
END,
-- text_value: non-numeric values
CASE
WHEN jsonb_typeof(je.value) = 'string' THEN je.value #>> '{}'
WHEN jsonb_typeof(je.value) = 'boolean' THEN
CASE WHEN je.value::text = 'true' THEN 'true' ELSE 'false' END
ELSE NULL
END,
-- raw_text: original string representation for all types
je.value #>> '{}',
false, -- not user-edited (backfilled from import)
false, -- not superseded (current active version)
kd."createdAt",
NOW()
FROM "KDocument" kd,
jsonb_each(kd."data"::jsonb) AS je(key, value);
Note on column names: If
@@mapis adopted,kd."createdAt"stays as PascalCase because the KDocument table is NOT being remapped (existing table). The newk1_line_itemtable uses snake_case via@@map. Adjust based on final naming decision.
Validation query (run after migration to verify SC-001 parity):
-- Count: JSON keys per KDocument vs K1LineItem rows per KDocument
SELECT
kd.id,
jsonb_object_keys_count.json_key_count,
li_count.line_item_count,
jsonb_object_keys_count.json_key_count = li_count.line_item_count AS parity_ok
FROM "KDocument" kd
LEFT JOIN LATERAL (
SELECT COUNT(*) AS json_key_count FROM jsonb_each(kd."data"::jsonb)
) jsonb_object_keys_count ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS line_item_count FROM "k1_line_item" li WHERE li."k_document_id" = kd.id
) li_count ON true;
5. isSuperseded Soft Versioning Pattern (FR-016)
Decision
Use a raw SQL partial unique index instead of @@unique([kDocumentId, boxKey, isSuperseded]). The business rule is: at most one active (non-superseded) row per kDocumentId + boxKey. Superseded rows are historical and should not be constrained.
Rationale
The problem with @@unique([kDocumentId, boxKey, isSuperseded]):
This allows exactly:
- One row with
(docA, box1, false)— the active row ✓ - One row with
(docA, box1, true)— one superseded row ✓ - ❌ But NOT two superseded rows for the same box (e.g., DRAFT → ESTIMATED → FINAL produces two superseded versions)
If a K-1 goes through DRAFT → ESTIMATED → FINAL, there would be 3 versions of each box:
- DRAFT version →
isSuperseded = true - ESTIMATED version →
isSuperseded = true - FINAL version →
isSuperseded = false
The @@unique([kDocumentId, boxKey, isSuperseded]) constraint would reject the second superseded row. This is a blocker.
| Option | Allows multiple superseded rows? | Prisma-native? | Verdict |
|---|---|---|---|
(a) @@unique([kDocumentId, boxKey]) (original spec) |
❌ No superseded rows at all | Yes | Rejected — breaks versioning |
(b) @@unique([kDocumentId, boxKey, isSuperseded]) |
❌ Max 1 superseded | Yes | Rejected — breaks multi-version |
| (c) Partial unique index (raw SQL) | ✅ Unlimited superseded, exactly 1 active | No — requires migration SQL | Chosen |
(d) @@unique([kDocumentId, boxKey, createdAt]) |
✅ Technically yes | Yes | Rejected — createdAt in unique constraint is odd; doesn't enforce "one active" |
Prisma and partial indexes: Prisma does not support partial unique indexes natively. There is no syntax for @@unique(..., where: ...). However, Prisma tolerates partial indexes — they are invisible to the Prisma client but enforced by PostgreSQL. The approach:
- In
schema.prisma: Use@@index([kDocumentId, boxKey])(regular index, NOT unique) for query performance. - In the migration
.sql: Manually add a partial unique index. - Prisma Client won't generate a unique constraint violation type for this index, but PostgreSQL will enforce it and Prisma will surface the error as a
PrismaClientKnownRequestErrorwith codeP2002.
Code Example
Prisma model (no @@unique on these columns — just indexes):
model K1LineItem {
id String @id @default(uuid())
kDocumentId String @map("k_document_id")
kDocument KDocument @relation(fields: [kDocumentId], onDelete: Cascade, references: [id])
boxKey String @map("box_key")
boxDefinition K1BoxDefinition @relation(fields: [boxKey], references: [boxKey])
amount Decimal? @db.Decimal(15, 2)
textValue String? @map("text_value")
rawText String? @map("raw_text")
confidence Decimal? @db.Decimal(3, 2)
sourcePage Int? @map("source_page")
sourceCoords Json? @map("source_coords")
isUserEdited Boolean @default(false) @map("is_user_edited")
isSuperseded Boolean @default(false) @map("is_superseded")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("k1_line_item")
@@index([kDocumentId, boxKey])
@@index([kDocumentId])
@@index([boxKey])
@@index([isSuperseded])
}
Migration SQL (appended to the create-table migration):
-- Partial unique index: enforce at most one ACTIVE (non-superseded) row per document + box.
-- Superseded rows (historical versions) are unrestricted.
CREATE UNIQUE INDEX "k1_line_item_active_unique"
ON "k1_line_item" ("k_document_id", "box_key")
WHERE "is_superseded" = false;
Service-level supersede logic (in K1ImportService or a dedicated K1LineItemService):
async supersedAndInsert(
kDocumentId: string,
newLineItems: Array<{ boxKey: string; amount: number | null; textValue: string | null; /* ... */ }>
) {
await this.prismaService.$transaction(async (tx) => {
// Mark all existing active rows as superseded
await tx.k1LineItem.updateMany({
where: { kDocumentId, isSuperseded: false },
data: { isSuperseded: true }
});
// Insert new active rows
await tx.k1LineItem.createMany({
data: newLineItems.map(item => ({
kDocumentId,
boxKey: item.boxKey,
amount: item.amount,
textValue: item.textValue,
isSuperseded: false,
// ... other fields
}))
});
});
}
Query pattern (all aggregation queries use this filter):
// Active line items only
const items = await this.prismaService.k1LineItem.findMany({
where: { kDocumentId, isSuperseded: false }
});
Migration safety note: When running prisma migrate dev in the future, Prisma may warn that k1_line_item_active_unique is not reflected in the schema. This is expected — Prisma does not model partial indexes. Add a comment in schema.prisma above the model:
/// NOTE: A partial unique index "k1_line_item_active_unique" exists on (k_document_id, box_key)
/// WHERE is_superseded = false. Managed in migration SQL, not expressible in Prisma schema.
model K1LineItem {
// ...
}
Summary of Decisions
| # | Question | Decision | Key Tradeoff |
|---|---|---|---|
| 1 | COMMENT ON in Prisma |
Raw SQL appended to migration .sql files; @@map/@map for snake_case naming |
Manual maintenance vs. version-controlled + atomic |
| 2 | Materialized Views | Raw SQL migration + $executeRawUnsafe() refresh + @OnEvent |
Prisma type safety lost for MV queries; use $queryRaw<T> |
| 3 | K1BoxDefinition composite key | Split: K1BoxDefinition (global PK=boxKey) + K1BoxOverride (per-partnership display) |
Extra table vs. clean FK semantics |
| 4 | JSON backfill | Single SQL INSERT...SELECT with jsonb_each() + jsonb_typeof() in migration |
Complex SQL vs. atomic + fast |
| 5 | isSuperseded versioning |
Partial unique index via raw SQL in migration | Not Prisma-native vs. correct multi-version semantics |