Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions prisma/schema/key-collaboration.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// prisma/schema/key-collaboration.prisma
//
// Read models for co-creator rosters (#1139), creator tips (#1132) and
// key reserves (#1140), maintained by the indexer from CoCreatorSet /
// CoCreatorRemoved, TipSent and ReserveAccrued / ReserveDeployed contract
// events.
//
// Design notes:
// - `keyId` is intentionally NOT a foreign key: an event that arrives
// before CREATOR_REGISTERED has been indexed is stored, not dropped
// (same rationale as KeyDepth).
// - KeyCoCreator is an order-safe upsert: the row carries the
// (ledger, eventIndex) watermark of the last applied event and only a
// strictly newer event mutates it, so replays and out-of-order delivery
// converge on the latest on-chain state.
// - KeyTip / KeyReserveEvent are append-only; (txHash, eventIndex)
// uniqueness makes replayed events no-ops. The running totals on
// KeyTipTotal and KeyReserve are updated in the same transaction as the
// event row, so a total can never drift from its event log.
// - Monetary amounts are stored as Decimal XLM (7 dp), converted from
// stroops by the indexer.

/// Active + historical co-creator roster per key (#1139).
model KeyCoCreator {
id String @id @default(cuid())
/// Key ID (CreatorProfile.id) the co-creator belongs to.
keyId String
/// Stellar address (G...) of the co-creator.
wallet String
/// Role label served by GET /keys/:key_id/co-creators.
role String @default("co_creator")
/// On-chain revenue share in basis points (CoCreatorSet.split_bps).
shareBps Int?
/// false after CoCreatorRemoved; excluded from the active roster list.
active Boolean @default(true)
addedAt DateTime @default(now())
removedAt DateTime?
/// Position of the last applied event; older events are ignored.
ledger Int?
eventIndex Int?
txHash String?
updatedAt DateTime @updatedAt

@@unique([keyId, wallet])
@@index([keyId, active])
@@map("key_cocreators")
}

/// One row per indexed TipSent event (#1132).
model KeyTip {
id String @id @default(cuid())
/// Key ID (CreatorProfile.id) that received the tip.
keyId String
/// Stellar address of the tipper.
tipper String
/// Tip amount in XLM (converted from stroops).
amount Decimal @db.Decimal(20, 7)
/// IPFS CID of the tip message, when one was attached.
messageHash String?
ledger Int?
/// Chain position; unique with eventIndex so replays are no-ops.
txHash String?
eventIndex Int?
/// When the tip happened on chain.
tippedAt DateTime
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([keyId, tippedAt])
@@map("key_tips")
}

/// Cumulative tips per key, maintained transactionally with KeyTip rows.
model KeyTipTotal {
id String @id @default(cuid())
keyId String @unique
total Decimal @default(0) @db.Decimal(20, 7)
count Int @default(0)
updatedAt DateTime @updatedAt

@@map("key_tip_totals")
}

/// Current reserve balance per key (#1140), maintained from
/// ReserveAccrued / ReserveDeployed events.
model KeyReserve {
id String @id @default(cuid())
keyId String @unique
/// Current reserve balance in XLM.
balance Decimal @default(0) @db.Decimal(20, 7)
/// Lifetime accrued (XLM).
totalAccrued Decimal @default(0) @db.Decimal(20, 7)
/// Lifetime deployed (XLM).
totalDeployed Decimal @default(0) @db.Decimal(20, 7)
updatedAt DateTime @updatedAt

@@map("key_reserves")
}

/// Append-only reserve event log; (txHash, eventIndex) uniqueness makes
/// replayed events no-ops so the balance above never double-counts.
model KeyReserveEvent {
id String @id @default(cuid())
keyId String
/// "accrued" | "deployed".
type String
/// Amount in XLM.
amount Decimal @db.Decimal(20, 7)
ledger Int?
txHash String?
eventIndex Int?
createdAt DateTime @default(now())

@@unique([txHash, eventIndex])
@@index([keyId])
@@map("key_reserve_events")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
-- Co-creator roster (#1139), tip history (#1132) and reserve balances
-- (#1140). Hand-written (not generated): the repo's shadow-database replay
-- is broken for full-history generation (see docs/database-migrations.md).

-- CreateTable
CREATE TABLE "key_cocreators" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'co_creator',
"shareBps" INTEGER,
"active" BOOLEAN NOT NULL DEFAULT true,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"removedAt" TIMESTAMP(3),
"ledger" INTEGER,
"eventIndex" INTEGER,
"txHash" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "key_cocreators_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "key_tips" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"tipper" TEXT NOT NULL,
"amount" DECIMAL(20,7) NOT NULL,
"messageHash" TEXT,
"ledger" INTEGER,
"txHash" TEXT,
"eventIndex" INTEGER,
"tippedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "key_tips_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "key_tip_totals" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"total" DECIMAL(20,7) NOT NULL DEFAULT 0,
"count" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "key_tip_totals_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "key_reserves" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"balance" DECIMAL(20,7) NOT NULL DEFAULT 0,
"totalAccrued" DECIMAL(20,7) NOT NULL DEFAULT 0,
"totalDeployed" DECIMAL(20,7) NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "key_reserves_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "key_reserve_events" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"amount" DECIMAL(20,7) NOT NULL,
"ledger" INTEGER,
"txHash" TEXT,
"eventIndex" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "key_reserve_events_pkey" PRIMARY KEY ("id"),
CONSTRAINT "key_reserve_events_type_check" CHECK ("type" IN ('accrued', 'deployed')),
CONSTRAINT "key_reserve_events_amount_check" CHECK ("amount" >= 0)
);

-- CreateIndex
CREATE UNIQUE INDEX "key_cocreators_keyId_wallet_key" ON "key_cocreators"("keyId", "wallet");

-- CreateIndex
CREATE INDEX "key_cocreators_keyId_active_idx" ON "key_cocreators"("keyId", "active");

-- CreateIndex
CREATE UNIQUE INDEX "key_tips_txHash_eventIndex_key" ON "key_tips"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "key_tips_keyId_tippedAt_idx" ON "key_tips"("keyId", "tippedAt");

-- CreateIndex
CREATE UNIQUE INDEX "key_tip_totals_keyId_key" ON "key_tip_totals"("keyId");

-- CreateIndex
CREATE UNIQUE INDEX "key_reserves_keyId_key" ON "key_reserves"("keyId");

-- CreateIndex
CREATE UNIQUE INDEX "key_reserve_events_txHash_eventIndex_key" ON "key_reserve_events"("txHash", "eventIndex");

-- CreateIndex
CREATE INDEX "key_reserve_events_keyId_idx" ON "key_reserve_events"("keyId");
64 changes: 64 additions & 0 deletions src/constants/redis.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,67 @@ export const keyDepthSpreadCacheKey = (keyId: string): string =>

export const keyDepthCachePattern = (keyId: string): string =>
`depth:${keyId}:*`;

// ── Co-creator roster endpoints (#1139) ─────────────────────────
// GET /keys/:key_id/co-creators and /co-creators/:wallet are served from
// the indexed key_cocreators table with a 60s TTL. Both the list and any
// per-wallet detail share the `key:cocreators:{keyId}:` prefix so the
// indexer can drop everything for a key with one pattern.
export const KEY_COCREATOR_CACHE_TTL_SECONDS = 60;

export const keyCoCreatorListCacheKey = (keyId: string): string =>
`key:cocreators:${keyId}:list`;

export const keyCoCreatorDetailCacheKey = (
keyId: string,
wallet: string
): string => `key:cocreators:${keyId}:${wallet}`;

export const keyCoCreatorCachePattern = (keyId: string): string =>
`key:cocreators:${keyId}:*`;

// ── Reserve balance endpoint (#1140) ────────────────────────────
// GET /keys/:key_id/reserve is served from the indexed key_reserves table
// with a 60s TTL, invalidated per key on every ReserveAccrued /
// ReserveDeployed event.
export const KEY_RESERVE_CACHE_TTL_SECONDS = 60;

export const keyReserveCacheKey = (keyId: string): string =>
`key:reserve:${keyId}`;

// ── Content access endpoint (#1137) ─────────────────────────────
// GET /keys/:key_id/content-access/:wallet is evaluated on chain (or
// off-chain from indexed state) with a 30s TTL. Invalidated per key on
// KEY_BOUGHT/KEY_SOLD (balance changes) so a just-completed trade is
// visible immediately.
export const CONTENT_ACCESS_CACHE_TTL_SECONDS = 30;

export const contentAccessCacheKey = (
keyId: string,
wallet: string
): string => `key:content-access:${keyId}:${wallet}`;

export const contentAccessCachePattern = (keyId: string): string =>
`key:content-access:${keyId}:*`;

// ── Tip history endpoints (#1132) ───────────────────────────────
// GET /keys/:key_id/tips and /tips/total are served from the indexed
// key_tips / key_tip_totals tables with a 60s TTL. IPFS tip message
// content fetched on demand is cached longer (immutable per CID).
export const KEY_TIPS_CACHE_TTL_SECONDS = 60;
export const KEY_TIP_CONTENT_CACHE_TTL_SECONDS = 3600;

export const keyTipsListCacheKey = (
keyId: string,
limit: number,
cursor: string
): string => `key:tips:${keyId}:list:${limit}:${cursor}`;

export const keyTipsTotalCacheKey = (keyId: string): string =>
`key:tips:${keyId}:total`;

export const keyTipContentCacheKey = (messageHash: string): string =>
`key:tips:content:${messageHash}`;

export const keyTipsCachePattern = (keyId: string): string =>
`key:tips:${keyId}:*`;
Loading
Loading