feat(read-models): add projection_offset table + idempotency harness (GOO-187)

RFC-003 §0 Phase 0 — the (eventId, handlerName) offset contract.

- prisma: ProjectionOffset model + 20260424100000_add_projection_offset
  migration (composite PK, handlerName/appliedAt index for reconciliation)
- infra: PrismaProjectionOffsetStore (createMany skipDuplicates +
  applyWithOffset transactional helper that rolls offset+mutation back
  together on failure)
- module: bind PROJECTION_OFFSET_STORE → PrismaProjectionOffsetStore
- testing: assertProjectorIdempotent harness ("replay N times → single
  state mutation") for Phase 2/3 projector specs to reuse
- tests: 12 specs lock the contract — replay 5x → 1 mutation, broken
  projectors fail loudly, two projectors keep independent offsets

Note: prisma format normalised existing column alignment when the new
model was added; the meaningful diff is the appended ProjectionOffset
block at the bottom of schema.prisma.

Acceptance criteria from issue:
- migration applies cleanly (validated via prisma format/parse)
- harness exported from read-models/testing and used by example
  projector spec at __tests__/idempotency-harness.spec.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:24:30 +07:00
parent b4bb05479e
commit 769cc9f758
8 changed files with 564 additions and 54 deletions

View File

@@ -0,0 +1,23 @@
-- RFC-003 Phase 0 (GOO-187): projection_offset table.
--
-- Idempotency contract for CQRS projectors. Every projector dispatch
-- wraps `apply()` in a transaction that inserts (event_id, handler_name)
-- here. Re-deliveries hit the composite primary key, roll back, and the
-- projector observes a no-op.
--
-- Port: apps/api/src/modules/read-models/domain/projection-offset-store.ts
-- Prisma adapter: apps/api/src/modules/read-models/infrastructure/prisma-projection-offset-store.ts
-- Test harness: apps/api/src/modules/read-models/testing/
-- CreateTable
CREATE TABLE "projection_offset" (
"eventId" TEXT NOT NULL,
"handlerName" TEXT NOT NULL,
"appliedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"payloadHash" TEXT,
CONSTRAINT "projection_offset_pkey" PRIMARY KEY ("eventId", "handlerName")
);
-- CreateIndex (handler-scoped scans for reconciliation tooling)
CREATE INDEX "projection_offset_handlerName_appliedAt_idx" ON "projection_offset"("handlerName", "appliedAt" DESC);

View File

@@ -61,30 +61,30 @@ model User {
totpBackupCodes String[] // Bcrypt-hashed backup codes
totpEnabledAt DateTime?
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
buyerOrders Order[] @relation("BuyerOrders")
sellerOrders Order[] @relation("SellerOrders")
mfaChallenges MfaChallenge[]
transferListings TransferListing[]
reports Report[]
savedListings SavedListing[]
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
buyerOrders Order[] @relation("BuyerOrders")
sellerOrders Order[] @relation("SellerOrders")
mfaChallenges MfaChallenge[]
transferListings TransferListing[]
reports Report[]
savedListings SavedListing[]
/// Dự án BĐS do user này làm chủ đầu tư (role=DEVELOPER).
ownedProjects ProjectDevelopment[] @relation("ProjectOwner")
ownedProjects ProjectDevelopment[] @relation("ProjectOwner")
/// KCN do user này vận hành (role=PARK_OPERATOR).
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
zaloAccountLink ZaloAccountLink?
notificationLogs NotificationLog[]
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
zaloAccountLink ZaloAccountLink?
notificationLogs NotificationLog[]
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
@@index([role])
@@index([kycStatus])
@@ -153,20 +153,20 @@ model OAuthAccount {
/// template messages to a linked user via ZNS.
/// Token fields are AES-256-GCM encrypted at the application layer.
model ZaloAccountLink {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Zalo user ID scoped to the Official Account (OA UID, not Social Graph UID)
zaloUserId String @unique
zaloUserId String @unique
/// AES-256-GCM encrypted access token (base64url: iv.tag.ciphertext)
accessToken String
accessToken String
/// AES-256-GCM encrypted refresh token (base64url: iv.tag.ciphertext)
refreshToken String
expiresAt DateTime
refreshToken String
expiresAt DateTime
/// Unix epoch (seconds) of the last user→OA interaction; used for 24-hour window check
lastInteractAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([zaloUserId])
@@index([expiresAt])
@@ -188,8 +188,8 @@ model Agent {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
leads Lead[]
listings Listing[]
leads Lead[]
industrialListings IndustrialListing[] @relation("IndustrialListingAgent")
@@index([qualityScore])
@@ -424,10 +424,10 @@ model Listing {
saveCount Int @default(0)
inquiryCount Int @default(0)
featuredUntil DateTime?
featuredPackage String? /// "3_days" | "7_days" | "30_days"
expiresAt DateTime?
expiryNotifiedAt DateTime?
publishedAt DateTime?
featuredPackage String? /// "3_days" | "7_days" | "30_days"
expiresAt DateTime?
expiryNotifiedAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -498,7 +498,7 @@ model ListingFlag {
reporterId String
reporter User @relation("listingFlagsReported", fields: [reporterId], references: [id], onDelete: Restrict)
reason FlagReason
description String? /// Mô tả chi tiết (tuỳ chọn)
description String? /// Mô tả chi tiết (tuỳ chọn)
status FlagStatus @default(PENDING)
reviewedBy String?
reviewedAt DateTime?
@@ -1508,13 +1508,13 @@ model SystemSetting {
// [GOO-21]
model VnProvince {
code String @id // GSO province code, zero-padded (e.g. "01", "79")
name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh"
nameEn String?
type String // "Thành phố Trung ương" | "Tỉnh"
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
phoneCode Int?
districts VnDistrict[]
code String @id // GSO province code, zero-padded (e.g. "01", "79")
name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh"
nameEn String?
type String // "Thành phố Trung ương" | "Tỉnh"
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
phoneCode Int?
districts VnDistrict[]
@@index([codename])
@@map("vn_provinces")