diff --git a/apps/web/__tests__/unit/developer-api-contract.test.ts b/apps/web/__tests__/unit/developer-api-contract.test.ts index 4a2c65a0400..c016bf57cfb 100644 --- a/apps/web/__tests__/unit/developer-api-contract.test.ts +++ b/apps/web/__tests__/unit/developer-api-contract.test.ts @@ -115,6 +115,7 @@ import { INFRA_RESOLUTION_TIERS, INFRA_STORAGE_DOLLARS_PER_1000_MINUTES_PER_MONTH, } from "@/lib/infra-pricing"; +import { getAppStorage } from "@/lib/infra-storage"; type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; @@ -157,6 +158,11 @@ const rateLimit = (response: Response) => ({ retryAfter: response.headers.get("Retry-After"), }); +const billingOf = async (billing: Record) => ({ + ...billing, + stored: (await getAppStorage("app-1")).stored, +}); + beforeEach(() => { vi.clearAllMocks(); mocks.keyRows = [{ appId: "app-1", environment: "development" }]; @@ -183,7 +189,9 @@ beforeEach(() => { }, ]; mocks.selected = []; - mocks.billing.mockResolvedValue({ standing: { state: "active" } }); + mocks.billing.mockImplementation(() => + billingOf({ standing: { state: "active" } }), + ); mocks.createVideo.mockResolvedValue({ status: 200, body: { videoId: "video-1" }, @@ -898,7 +906,9 @@ describe("usage", () => { }); it("returns the fields the OpenAPI description requires", async () => { - mocks.billing.mockResolvedValue({ standing: {}, upload: {}, storage: {} }); + mocks.billing.mockImplementation(() => + billingOf({ standing: {}, upload: {}, storage: {} }), + ); const schema = developerOpenApiSpec(ORIGIN).components.schemas.Usage; const data = await usage(); diff --git a/apps/web/__tests__/unit/developer-cron-storage.test.ts b/apps/web/__tests__/unit/developer-cron-storage.test.ts index ad67787a9fb..7bf720306e0 100644 --- a/apps/web/__tests__/unit/developer-cron-storage.test.ts +++ b/apps/web/__tests__/unit/developer-cron-storage.test.ts @@ -2,49 +2,96 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ selects: [] as Record[][], + page: [] as Record[], + accountsTable: { id: "id", appId: "appId" }, + appsTable: { id: "id", deletedAt: "deletedAt" }, + ledgerTable: { + accountId: "accountId", + referenceId: "referenceId", + referenceType: "referenceType", + }, + snapshotsTable: { appId: "appId", snapshotDate: "snapshotDate" }, + yesterdayBilled: null as Record[] | null, + markers: [] as unknown[], getStorageUsageByApp: vi.fn(), chargeStorageDay: vi.fn(), - latestStorageCharge: vi.fn(), + latestStorageCharges: vi.fn(), runDueAutoTopUps: vi.fn(), sendInfraOutOfCreditEmail: vi.fn(), })); vi.mock("@cap/database", () => ({ db: () => { + let table: unknown; + const dateOf = (condition: unknown) => { + const match = JSON.stringify(condition).match( + /\{"eq":\["referenceId","(\d{4}-\d{2}-\d{2})"\]\}/, + ); + return match?.[1] ?? null; + }; + const read = (condition: unknown) => { + if (table === mocks.appsTable) { + return mocks.page.map((account) => ({ id: account.appId })); + } + if (table === mocks.accountsTable) { + mocks.page = mocks.selects[0] ?? []; + return mocks.page; + } + if (table === mocks.ledgerTable) { + if (dateOf(condition) === new Date().toISOString().slice(0, 10)) { + return mocks.selects[1] ?? []; + } + return ( + mocks.yesterdayBilled ?? + mocks.page.map((account) => ({ accountId: account.id })) + ); + } + return []; + }; const chain = { select: () => chain, - from: () => chain, - innerJoin: () => chain, - where: async () => mocks.selects.shift() ?? [], + from: (next: unknown) => { + table = next; + return chain; + }, + where: (condition: unknown) => { + const rows = Promise.resolve(read(condition)); + return Object.assign(rows, { + orderBy: () => ({ limit: () => rows }), + }); + }, }; return chain; }, })); vi.mock("@cap/database/schema", () => ({ - developerApps: { id: "id", deletedAt: "deletedAt" }, - developerCreditAccounts: { id: "id", appId: "appId" }, - developerCreditTransactions: { - accountId: "accountId", - referenceId: "referenceId", - referenceType: "referenceType", - }, + developerApps: mocks.appsTable, + developerCreditAccounts: mocks.accountsTable, + developerCreditTransactions: mocks.ledgerTable, + developerDailyStorageSnapshots: mocks.snapshotsTable, })); vi.mock("drizzle-orm", () => ({ and: (...args: unknown[]) => args, + asc: (a: unknown) => ({ asc: a }), + gt: (a: unknown, b: unknown) => ({ gt: [a, b] }), eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), inArray: (a: unknown, b: unknown) => ({ inArray: [a, b] }), isNull: (a: unknown) => ({ isNull: a }), + isNotNull: (a: unknown) => ({ isNotNull: a }), })); vi.mock("@/lib/infra-storage", () => ({ getStorageUsageByApp: mocks.getStorageUsageByApp, })); vi.mock("@/lib/infra-storage-billing", () => ({ chargeStorageDay: mocks.chargeStorageDay, + recordStorageDayVisits: async ({ appIds }: { appIds: string[] }) => { + mocks.markers.push(...appIds); + }, })); vi.mock("@/lib/infra-standing", () => ({ INFRA_GRACE_DAYS: 7, STORAGE_DAY_REFERENCE_TYPE: "storage_day", - latestStorageCharge: mocks.latestStorageCharge, + latestStorageCharges: mocks.latestStorageCharges, })); vi.mock("@/lib/infra-auto-top-up", () => ({ runDueAutoTopUps: mocks.runDueAutoTopUps, @@ -90,17 +137,20 @@ const authorized = () => run(`Bearer ${CRON_SECRET}`); beforeEach(() => { process.env.CRON_SECRET = CRON_SECRET; mocks.selects = []; + mocks.page = []; + mocks.yesterdayBilled = null; + mocks.markers = []; for (const mock of [ mocks.getStorageUsageByApp, mocks.chargeStorageDay, - mocks.latestStorageCharge, + mocks.latestStorageCharges, mocks.runDueAutoTopUps, mocks.sendInfraOutOfCreditEmail, ]) { mock.mockReset(); } mocks.runDueAutoTopUps.mockResolvedValue(NO_TOP_UPS); - mocks.latestStorageCharge.mockResolvedValue(null); + mocks.latestStorageCharges.mockResolvedValue(new Map()); mocks.chargeStorageDay.mockResolvedValue(billed()); }); @@ -191,6 +241,34 @@ describe("developer-storage cron job", () => { expect(body.appsProcessed).toBe(0); expect(mocks.chargeStorageDay).not.toHaveBeenCalled(); + expect(mocks.markers).toEqual(["app-1"]); + }); + + it("looks up the last charge of every idle account in one read", async () => { + mocks.selects = [ + [ + { id: "account-1", appId: "app-1" }, + { id: "account-2", appId: "app-2" }, + { id: "account-3", appId: "app-3" }, + ], + [], + ]; + mocks.getStorageUsageByApp.mockResolvedValue( + new Map([ + ["app-1", usage(0)], + ["app-2", usage(28)], + ["app-3", usage(0)], + ]), + ); + + const { body } = await authorized(); + + expect(body).toMatchObject({ complete: true, appsProcessed: 1 }); + expect(mocks.latestStorageCharges).toHaveBeenCalledTimes(1); + expect(mocks.latestStorageCharges).toHaveBeenCalledWith(expect.anything(), [ + "account-1", + "account-3", + ]); }); it("still settles an app that stores nothing now but is short from before", async () => { @@ -198,14 +276,24 @@ describe("developer-storage cron job", () => { mocks.getStorageUsageByApp.mockResolvedValue( new Map([["app-1", usage(0)]]), ); - mocks.latestStorageCharge.mockResolvedValue({ - createdAt: new Date(), - owedMicroCredits: 28, - shortSince: new Date(), - }); + mocks.latestStorageCharges.mockResolvedValue( + new Map([ + [ + "account-1", + { + createdAt: new Date(), + owedMicroCredits: 28, + shortSince: new Date(), + }, + ], + ]), + ); await authorized(); + expect(mocks.latestStorageCharges).toHaveBeenCalledWith(expect.anything(), [ + "account-1", + ]); expect(mocks.chargeStorageDay).toHaveBeenCalledTimes(1); }); diff --git a/apps/web/__tests__/unit/developer-usage-counts.test.ts b/apps/web/__tests__/unit/developer-usage-counts.test.ts index 1b3373f3953..50ebc8eac9a 100644 --- a/apps/web/__tests__/unit/developer-usage-counts.test.ts +++ b/apps/web/__tests__/unit/developer-usage-counts.test.ts @@ -87,6 +87,7 @@ vi.mock("@/lib/infra-standing", async (original) => ({ import { app } from "@/app/api/developer/v1/[...route]/usage"; import { storageUsage } from "@/lib/developer-pricing"; +import { getAppStorage } from "@/lib/infra-storage"; const usage = async () => { const response = await app.request("/", { @@ -99,9 +100,10 @@ const usage = async () => { beforeEach(() => { mocks.balanceMicroCredits = 2_999_901; mocks.videos = []; - mocks.billing.mockResolvedValue({ + mocks.billing.mockImplementation(async (appId: string) => ({ standing: { state: "active", pausesAt: null }, usage: storageUsage([]), + stored: (await getAppStorage(appId)).stored, admission: { allowed: true, reason: null, @@ -109,7 +111,7 @@ beforeEach(() => { shortfallMicroCredits: 0, message: null, }, - }); + })); }); describe("GET /usage totals", () => { diff --git a/apps/web/__tests__/unit/developer-usage-route.test.ts b/apps/web/__tests__/unit/developer-usage-route.test.ts index 7054c4816ff..ca34d52280c 100644 --- a/apps/web/__tests__/unit/developer-usage-route.test.ts +++ b/apps/web/__tests__/unit/developer-usage-route.test.ts @@ -33,16 +33,15 @@ vi.mock("@/lib/developer-key-hash", () => ({ hashKey: async (key: string) => `hash:${key}`, })); vi.mock("@/lib/infra-standing", () => ({ - getInfraBilling: async () => ({}), + getInfraBilling: async () => ({ + stored: { videoCount: 2, totalMinutes: 12.5 }, + }), infraBillingJson: () => ({ standing: { state: "active", pausesAt: null }, upload: { allowed: true }, }), infraStorageJson: () => ({ dailyMicroCredits: 0 }), })); -vi.mock("@/lib/infra-storage", () => ({ - getAppStorageUsage: async () => ({ videoCount: 2, totalMinutes: 12.5 }), -})); vi.mock("@/lib/developer-transcript-usage", () => ({ getSpeechUsage: mocks.speech, })); diff --git a/apps/web/__tests__/unit/developer-video-upload.test.ts b/apps/web/__tests__/unit/developer-video-upload.test.ts index 251fbecce06..1465c6f768c 100644 --- a/apps/web/__tests__/unit/developer-video-upload.test.ts +++ b/apps/web/__tests__/unit/developer-video-upload.test.ts @@ -99,7 +99,7 @@ vi.mock("@/lib/infra-storage", async () => { const { billableVideo, storageUsage } = await import( "@/lib/developer-pricing" ); - return { + const storage = { getAppStorageUsage: async ( appId: string, newVideo?: Parameters[0][number]["video"], @@ -129,6 +129,25 @@ vi.mock("@/lib/infra-storage", async () => { return storageUsage(entries); }, }; + return { + ...storage, + getAppStorage: async ( + ...[appId, newVideo, executor, options]: Parameters< + typeof storage.getAppStorageUsage + > + ) => ({ + stored: await storage.getAppStorageUsage( + appId, + newVideo, + executor, + options, + ), + counted: await storage.getAppStorageUsage(appId, newVideo, executor, { + ...options, + includeProcessing: true, + }), + }), + }; }); vi.mock("@/lib/developer-transcript-storage", () => ({ diff --git a/apps/web/__tests__/unit/infra-auto-top-up-sweep.test.ts b/apps/web/__tests__/unit/infra-auto-top-up-sweep.test.ts new file mode 100644 index 00000000000..c92274936df --- /dev/null +++ b/apps/web/__tests__/unit/infra-auto-top-up-sweep.test.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Account = { id: string; lastAttemptAt: Date | null }; + +const state = vi.hoisted(() => ({ + accounts: [] as { id: string; lastAttemptAt: Date | null }[], + pages: [] as { + sql: string; + params: unknown[]; + after: { key: string; id: string } | null; + limit: number; + }[], + reads: [] as string[], + msPerAccount: 0, + other: [] as { sql: string; params: unknown[] }[], +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const NEVER = "1970-01-01 00:00:00"; + const keyOf = (account: { lastAttemptAt: Date | null }) => + account.lastAttemptAt + ? account.lastAttemptAt.toISOString().slice(0, 19).replace("T", " ") + : NEVER; + const client = drizzle(async (sql, params) => { + if (sql.startsWith("select `developer_credit_accounts`.`id`, cast(")) { + const limit = Number(params.at(-1)); + const keyset = sql.includes(" or ("); + const after = keyset + ? { key: String(params.at(-4)), id: String(params.at(-2)) } + : null; + const cutoff = String( + params.find( + (param) => + typeof param === "string" && + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/.test(param), + ), + ); + state.pages.push({ sql, params, after, limit }); + const rows = state.accounts + .map((account) => ({ id: account.id, key: keyOf(account) })) + .filter((row) => row.key <= cutoff) + .filter( + (row) => + !after || + row.key > after.key || + (row.key === after.key && row.id > after.id), + ) + .sort((a, b) => + a.key === b.key ? a.id.localeCompare(b.id) : a.key < b.key ? -1 : 1, + ) + .slice(0, limit); + return { rows: rows.map((row) => [row.id, row.key]) }; + } + if (sql.startsWith("select `developer_credit_accounts`.`id` from")) { + const limit = Number(params.at(-1)); + const cursor = sql.includes("`id` > ?") ? String(params.at(-2)) : null; + state.pages.push({ + sql, + params, + after: cursor ? { key: "", id: cursor } : null, + limit, + }); + return { + rows: state.accounts + .filter((account) => cursor === null || account.id > cursor) + .slice(0, limit) + .map((account) => [account.id]), + }; + } + if (sql.includes("`autoTopUpAmountCents`")) { + state.reads.push(String(params[0])); + vi.setSystemTime(Date.now() + state.msPerAccount); + return { rows: [] }; + } + state.other.push({ sql, params }); + return { rows: [] }; + }); + return { db: () => client }; +}); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => "row", + nanoIdLength: 15, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); +vi.mock("@cap/utils", () => ({ stripe: () => ({}) })); +vi.mock("@/lib/infra-billing-emails", () => ({ + sendInfraTopUpFailedEmail: vi.fn(), +})); + +import { + autoTopUpRetryCutoff, + getAutoTopUpFailure, + runDueAutoTopUps, +} from "@/lib/infra-auto-top-up"; + +const NOW = new Date("2026-11-13T06:41:00Z"); +const HOUR_MS = 60 * 60 * 1000; + +const accounts = ( + count: number, + from = 0, + lastAttemptAt: Date | null = null, +): Account[] => + Array.from({ length: count }, (_, index) => ({ + id: `account-${String(from + index).padStart(4, "0")}`, + lastAttemptAt, + })); + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + state.accounts = accounts(450); + state.pages = []; + state.reads = []; + state.msPerAccount = 0; + state.other = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("auto top-up sweep selection", () => { + it("pages through due accounts by last attempt, then id", async () => { + await runDueAutoTopUps(NOW); + + expect(state.pages.map(({ after, limit }) => ({ after, limit }))).toEqual([ + { after: null, limit: 200 }, + { after: { key: "1970-01-01 00:00:00", id: "account-0199" }, limit: 200 }, + { after: { key: "1970-01-01 00:00:00", id: "account-0399" }, limit: 200 }, + ]); + expect(state.reads).toHaveLength(450); + }); + + it("asks the database for enabled accounts under their threshold after held credit, not tried since the retry cutoff", async () => { + state.accounts = []; + + await runDueAutoTopUps(NOW); + + expect(state.pages).toHaveLength(1); + const { sql, params } = state.pages[0] ?? { sql: "", params: [] }; + expect(sql).toContain("`autoTopUpEnabled` = ?"); + expect(sql).toContain("`stripePaymentMethodId` is not null"); + expect(sql).toContain( + "left join (select `appId`, COALESCE(SUM(`reservedMicroCredits`), 0) as `heldMicroCredits` from `developer_transcripts` where `developer_transcripts`.`status` = ? group by `developer_transcripts`.`appId`) `held` on `held`.`appId` = `developer_credit_accounts`.`appId`", + ); + expect(sql).toContain( + "`balanceMicroCredits` < `developer_credit_accounts`.`autoTopUpThresholdMicroCredits` + coalesce(`heldMicroCredits`, 0)", + ); + expect(sql).toContain( + "(select max(`developer_credit_transactions`.`createdAt`) from `developer_credit_transactions` where `developer_credit_transactions`.`accountId` = `developer_credit_accounts`.`id` and `developer_credit_transactions`.`type` in (?, ?, ?) and `developer_credit_transactions`.`referenceType` in (?, ?, ?))", + ); + expect(sql).toMatch( + /order by attemptKey, `developer_credit_accounts`\.`id` asc limit \?$/, + ); + expect(params).toContain( + autoTopUpRetryCutoff(NOW).toISOString().slice(0, 23).replace("T", " "), + ); + expect(autoTopUpRetryCutoff(NOW)).toEqual( + new Date(NOW.getTime() - 24 * HOUR_MS), + ); + }); + + it("tries the least recently tried account first", async () => { + state.accounts = [ + { + id: "account-0001", + lastAttemptAt: new Date(NOW.getTime() - 26 * HOUR_MS), + }, + { id: "account-0002", lastAttemptAt: null }, + { + id: "account-0003", + lastAttemptAt: new Date(NOW.getTime() - 30 * HOUR_MS), + }, + { + id: "account-0004", + lastAttemptAt: new Date(NOW.getTime() - 2 * HOUR_MS), + }, + { id: "account-0005", lastAttemptAt: null }, + ]; + + await runDueAutoTopUps(NOW); + + expect(state.reads).toEqual([ + "account-0002", + "account-0005", + "account-0003", + "account-0001", + ]); + }); + + it("reaches 50 due accounts in one run behind 300 accounts tried inside the retry window", async () => { + const tried = accounts(300, 0, new Date(NOW.getTime() - HOUR_MS)); + const due = accounts(50, 300); + state.accounts = [...tried, ...due]; + state.msPerAccount = 250; + + await runDueAutoTopUps(NOW, { until: NOW.getTime() + 60_000 }); + + expect(state.reads).toEqual(due.map((account) => account.id)); + }); + + it("stops between accounts when its time is up", async () => { + state.msPerAccount = 1000; + + await runDueAutoTopUps(NOW, { until: NOW.getTime() + 10_500 }); + + expect(state.reads).toHaveLength(11); + expect(state.pages).toHaveLength(1); + }); +}); + +describe("last auto top-up lookup", () => { + it("reads the account's top-up rows by type, so the account and type index serves it", async () => { + expect(await getAutoTopUpFailure("account-0001")).toBeNull(); + + expect(state.other).toHaveLength(1); + const [{ sql, params }] = state.other as [ + { sql: string; params: unknown[] }, + ]; + expect(sql).toContain( + "where (`developer_credit_transactions`.`accountId` = ? and `developer_credit_transactions`.`type` in (?, ?) and `developer_credit_transactions`.`referenceType` in (?, ?))", + ); + expect(sql).toContain( + "order by `developer_credit_transactions`.`createdAt` desc limit ?", + ); + expect(params).toEqual([ + "account-0001", + "topup", + "topup_failed", + "auto_top_up", + "auto_top_up_failed", + 1, + ]); + }); +}); diff --git a/apps/web/__tests__/unit/infra-auto-top-up.test.ts b/apps/web/__tests__/unit/infra-auto-top-up.test.ts index 151022dd457..c4f416818db 100644 --- a/apps/web/__tests__/unit/infra-auto-top-up.test.ts +++ b/apps/web/__tests__/unit/infra-auto-top-up.test.ts @@ -684,6 +684,46 @@ describe("auto top-up sweep", () => { failed: 1, }); }); + + it("does not read an account that is not due", async () => { + ledger.account.balanceMicroCredits = + ledger.account.autoTopUpThresholdMicroCredits; + const select = vi.spyOn(fakeDb, "select"); + + expect(await runDueAutoTopUps(now())).toEqual({ + attempted: 0, + succeeded: 0, + failed: 0, + }); + + expect( + select.mock.calls.filter(([fields]) => "autoTopUpAmountCents" in fields), + ).toHaveLength(0); + expect(mocks.createPaymentIntent).not.toHaveBeenCalled(); + select.mockRestore(); + }); + + it("still tops up an account that only held credit puts under its threshold", async () => { + ledger.account.balanceMicroCredits = + ledger.account.autoTopUpThresholdMicroCredits + 10; + ledger.heldMicroCredits = 11; + + expect(await runDueAutoTopUps(now())).toEqual({ + attempted: 1, + succeeded: 1, + failed: 0, + }); + }); + + it("starts no top-up once its time is up", async () => { + expect(await runDueAutoTopUps(now(), { until: Date.now() - 1 })).toEqual({ + attempted: 0, + succeeded: 0, + failed: 0, + }); + + expect(mocks.createPaymentIntent).not.toHaveBeenCalled(); + }); }); describe("billing emails", () => { diff --git a/apps/web/__tests__/unit/infra-billing-fake-db.ts b/apps/web/__tests__/unit/infra-billing-fake-db.ts index 69b9a4766f8..817fdfcaedf 100644 --- a/apps/web/__tests__/unit/infra-billing-fake-db.ts +++ b/apps/web/__tests__/unit/infra-billing-fake-db.ts @@ -5,7 +5,7 @@ import { developerTranscripts, developerVideos, } from "@cap/database/schema"; -import type { SQL } from "drizzle-orm"; +import { type SQL, sql } from "drizzle-orm"; import { MySqlDialect } from "drizzle-orm/mysql-core"; const dialect = new MySqlDialect(); @@ -102,6 +102,25 @@ const newestFirst = (rows: LedgerRow[]) => ledger.rows.indexOf(b) - ledger.rows.indexOf(a), ); +const byId = (a: LedgerRow, b: LedgerRow) => + a.id < b.id ? 1 : a.id > b.id ? -1 : 0; + +const latestWritten = (rows: LedgerRow[]) => + [...rows].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() || byId(a, b), + ); + +const latestStorageDayRows = () => { + const days = storageRows().filter( + (row) => row.referenceType === "storage_day" && row.referenceId, + ); + const latestDay = days + .map((row) => String(row.referenceId)) + .sort() + .at(-1); + return days.filter((row) => row.referenceId === latestDay).sort(byId); +}; + function runSelect(query: Query): Record[] { const keys = Object.keys(query.fields).sort().join(","); const rendered = query.where @@ -111,6 +130,41 @@ function runSelect(query: Query): Record[] { if (query.table === developerCreditAccounts) { const { exists, ...account } = ledger.account; if (!exists) return []; + if (keys === "attemptKey,id") { + const cutoff = rendered.params.find( + (param) => + typeof param === "string" && + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/.test(param), + ); + const attempts = ledger.rows + .filter((row) => + ["auto_top_up", "auto_top_up_failed", "auto_top_up_pending"].includes( + String(row.referenceType), + ), + ) + .map((row) => row.createdAt.getTime()); + const lastAttempt = attempts.length > 0 ? Math.max(...attempts) : null; + const due = + account.autoTopUpEnabled && + account.stripePaymentMethodId && + account.balanceMicroCredits < + account.autoTopUpThresholdMicroCredits + ledger.heldMicroCredits && + (lastAttempt === null || lastAttempt <= asTime(cutoff)); + return due + ? [ + { + id: account.id, + attemptKey: + lastAttempt === null + ? "1970-01-01 00:00:00" + : new Date(lastAttempt) + .toISOString() + .slice(0, 19) + .replace("T", " "), + }, + ] + : []; + } if ( keys === "id" && /`id` = \?$/.test(rendered.sql) && @@ -124,7 +178,8 @@ function runSelect(query: Query): Record[] { !( account.autoTopUpEnabled && account.stripePaymentMethodId && - account.balanceMicroCredits < account.autoTopUpThresholdMicroCredits + account.balanceMicroCredits < + account.autoTopUpThresholdMicroCredits + ledger.heldMicroCredits ) ) { return []; @@ -134,7 +189,14 @@ function runSelect(query: Query): Record[] { if (query.table === developerCreditTransactions) { if (keys === "createdAt,metadata") { - return newestFirst(storageRows()).slice(0, 1); + return rendered.sql.includes( + "`developer_credit_transactions`.`referenceId` <= ?", + ) + ? latestStorageDayRows().slice(0, 1) + : latestWritten(storageRows()).slice(0, 1); + } + if (keys === "accountId,createdAt,id,metadata") { + return latestStorageDayRows(); } if (keys === "metadata") { const since = asTime(rendered.params.at(-1)); @@ -228,12 +290,19 @@ function select(fields: Record) { return chain; }, innerJoin: () => chain, + leftJoin: () => chain, where(condition: SQL | undefined) { query.where = condition; const rows = Promise.resolve().then(() => runSelect(query)); const result = Object.assign(rows, { orderBy: () => result, - groupBy: () => result, + groupBy: () => + Object.assign(result, { + as: (alias: string) => ({ + appId: sql.raw(`\`${alias}\`.\`appId\``), + heldMicroCredits: sql.raw("`heldMicroCredits`"), + }), + }), limit: () => result, for: () => result, }); diff --git a/apps/web/__tests__/unit/infra-console-round-trips.test.ts b/apps/web/__tests__/unit/infra-console-round-trips.test.ts new file mode 100644 index 00000000000..07c2bd9b69b --- /dev/null +++ b/apps/web/__tests__/unit/infra-console-round-trips.test.ts @@ -0,0 +1,263 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as string[], + appId: "app-1", +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const CREATED = "2026-11-01 00:00:00"; + const accountValues: Record = { + id: "account-1", + appId: "app-1", + ownerId: "user-1", + balanceMicroCredits: 5_000_000, + stripeCustomerId: null, + stripePaymentMethodId: null, + autoTopUpEnabled: 0, + autoTopUpThresholdMicroCredits: 0, + autoTopUpAmountCents: 0, + createdAt: CREATED, + updatedAt: CREATED, + }; + const appValues: Record[] = [ + { id: "app-1", name: "First app", environment: "production" }, + { id: "app-2", name: "Second app", environment: "development" }, + ].map((app) => ({ + ...app, + ownerId: "user-1", + logoUrl: null, + deletedAt: null, + createdAt: CREATED, + updatedAt: CREATED, + })); + const selected = (sql: string) => + sql + .slice("select ".length, sql.indexOf(" from ")) + .split(", ") + .map((column) => column.replaceAll("`", "").split(".").at(-1) ?? ""); + const client = drizzle(async (sql) => { + state.queries.push(sql); + if (sql.includes("from `developer_apps`")) { + return { + rows: appValues.map((app) => selected(sql).map((name) => app[name])), + }; + } + if ( + sql.includes("from `developer_credit_accounts`") && + !sql.includes("select COALESCE") + ) { + return { rows: [selected(sql).map((name) => accountValues[name])] }; + } + if (sql.includes("COALESCE(SUM(") && !sql.includes("group by")) { + return { rows: [[0]] }; + } + if (sql.startsWith("select count(*)")) return { rows: [[0]] }; + return { rows: [] }; + }); + return { db: () => client }; +}); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: async () => ({ + id: "user-1", + email: "owner@example.com", + activeOrganizationId: null, + }), +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), + NODE_ENV: "production", +})); +vi.mock("next/font/google", () => { + const font = () => ({ className: "", variable: "", style: {} }); + return { Geist: font, Geist_Mono: font }; +}); +vi.mock("next/headers", () => ({ + cookies: async () => ({ get: () => undefined }), + headers: async () => new Headers(), +})); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("notFound"); + }, + redirect: (to: string) => { + throw new Error(`redirect ${to}`); + }, + usePathname: () => "/dashboard/infra/app-1", + useRouter: () => ({}), +})); +vi.mock("@/lib/infra-access", () => ({ canUseInfra: () => true })); +vi.mock("@/app/api/utils", async () => { + const { createMiddleware } = await import("hono/factory"); + return { + withDeveloperSecretAuth: createMiddleware(async (c, next) => { + c.set("developerAppId", state.appId); + await next(); + }), + }; +}); + +import InfraBillingPage from "@/app/(infra)/dashboard/infra/[appId]/billing/page"; +import InfraKeysPage from "@/app/(infra)/dashboard/infra/[appId]/keys/page"; +import InfraAppLayout from "@/app/(infra)/dashboard/infra/[appId]/layout"; +import InfraOverviewPage from "@/app/(infra)/dashboard/infra/[appId]/page"; +import InfraSettingsPage from "@/app/(infra)/dashboard/infra/[appId]/settings/page"; +import InfraUsagePage from "@/app/(infra)/dashboard/infra/[appId]/usage/page"; +import { app as usageApi } from "@/app/api/developer/v1/[...route]/usage"; +import { getInfraBilling, getInfraUploadAdmission } from "@/lib/infra-standing"; + +const params = { params: Promise.resolve({ appId: "app-1" }) }; + +const storageAggregates = () => + state.queries.filter( + (sql) => sql.includes("from `developer_videos`") && sql.includes("SUM("), + ); +const reads = (table: string) => + state.queries.filter((sql) => sql.includes(`from \`${table}\``)); + +type Element = { props: Record }; + +beforeEach(() => { + state.queries = []; +}); + +describe("storage totals are computed once per request", () => { + it("on the console overview", async () => { + await InfraOverviewPage(params); + + expect(storageAggregates()).toHaveLength(1); + expect(state.queries).toHaveLength(11); + }); + + it("on the console usage page", async () => { + await InfraUsagePage(params); + + expect(storageAggregates()).toHaveLength(1); + }); + + it("on the console billing page", async () => { + await InfraBillingPage({ ...params, searchParams: Promise.resolve({}) }); + + expect(storageAggregates()).toHaveLength(1); + }); + + it("on GET /usage", async () => { + const response = await usageApi.request("/"); + + expect(response.status).toBe(200); + expect(storageAggregates()).toHaveLength(1); + }); + + it("and not at all when an admission is given the totals it already has", async () => { + const billing = await getInfraBilling("app-1"); + state.queries = []; + + const admission = await getInfraUploadAdmission( + "app-1", + undefined, + undefined, + { storage: { stored: billing.stored, counted: billing.usage } }, + ); + + expect(storageAggregates()).toHaveLength(0); + expect(admission).toEqual(billing.admission); + }); +}); + +describe("the billing page lists the ledger without counting it", () => { + const counts = () => + state.queries.filter((sql) => sql.startsWith("select count(")); + const ledgerReads = () => + reads("developer_credit_transactions").filter((sql) => + sql.includes("inner join `developer_credit_accounts`"), + ); + + it("reads one page and no total", async () => { + const page = (await InfraBillingPage({ + ...params, + searchParams: Promise.resolve({}), + })) as Element; + + expect(ledgerReads()).toHaveLength(1); + expect(ledgerReads()[0]).toContain("limit ?"); + expect(ledgerReads()[0]).not.toContain("offset"); + expect(counts()).toHaveLength(0); + expect(page.props).toMatchObject({ + transactions: [], + ledgerPage: { newer: null, older: null }, + }); + expect(page.props).not.toHaveProperty("totalTransactions"); + }); + + it("starts from the cursor in the URL", async () => { + await InfraBillingPage({ + ...params, + searchParams: Promise.resolve({ before: "1794461929000.abc123" }), + }); + + expect(ledgerReads()).toHaveLength(1); + expect(ledgerReads()[0]).toContain("`createdAt` < ?"); + expect(counts()).toHaveLength(0); + }); +}); + +describe("the console loads only what a page shows", () => { + it("reads the app list and the open app's account for the shell, nothing else", async () => { + const layout = (await InfraAppLayout({ + children: null, + ...params, + })) as Element; + + expect(state.queries).toHaveLength(2); + expect(reads("developer_apps")).toHaveLength(1); + expect(reads("developer_credit_accounts")).toHaveLength(1); + expect(layout.props.balanceMicroCredits).toBe(5_000_000); + const shell = layout.props.children as Element; + expect(shell.props).toMatchObject({ + appId: "app-1", + balanceMicroCredits: 5_000_000, + apps: [ + { id: "app-1", name: "First app", environment: "production" }, + { id: "app-2", name: "Second app", environment: "development" }, + ], + }); + }); + + it("never loads domains, other apps' keys or a video count for the overview", async () => { + await InfraOverviewPage(params); + + expect(reads("developer_app_domains")).toHaveLength(0); + expect(reads("developer_api_keys")).toHaveLength(1); + expect(reads("developer_api_keys")[0]).toContain("`appId` = ?"); + expect( + state.queries.filter((sql) => sql.includes("count(*)")), + ).toHaveLength(0); + }); + + it("reads one app's keys for the keys page", async () => { + const page = (await InfraKeysPage(params)) as Element; + + expect(state.queries).toHaveLength(3); + expect(reads("developer_api_keys")[0]).toContain("`appId` = ?"); + expect(page.props).toEqual({ appId: "app-1", secretKeyPrefix: null }); + }); + + it("counts videos only on the settings page, and only for that app", async () => { + const page = (await InfraSettingsPage(params)) as Element; + + expect(state.queries).toHaveLength(3); + const [count] = reads("developer_videos"); + expect(count).toContain("count(*)"); + expect(count).toContain("`appId` = ?"); + expect(page.props).toMatchObject({ + appId: "app-1", + name: "First app", + environment: "production", + balanceMicroCredits: 5_000_000, + videoCount: 0, + }); + }); +}); diff --git a/apps/web/__tests__/unit/infra-ledger-paging.test.ts b/apps/web/__tests__/unit/infra-ledger-paging.test.ts new file mode 100644 index 00000000000..a24dac2170f --- /dev/null +++ b/apps/web/__tests__/unit/infra-ledger-paging.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = { id: string; createdAt: Date; amountMicroCredits: number }; + +const state = vi.hoisted(() => ({ + rows: [] as Row[], + queries: [] as string[], +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const toDate = (value: unknown) => + new Date(`${String(value).replace(" ", "T")}Z`); + const stamp = (date: Date) => + date.toISOString().slice(0, 19).replace("T", " "); + const client = drizzle(async (sql, params) => { + state.queries.push(sql); + const ascending = sql.includes( + "order by `developer_credit_transactions`.`createdAt` asc", + ); + const sorted = [...state.rows].sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + if (!ascending) sorted.reverse(); + let rest = params.slice(1); + let matching = sorted; + if (sql.includes("`createdAt` < ?") || sql.includes("`createdAt` > ?")) { + const at = toDate(rest[0]).getTime(); + const id = String(rest[2]); + rest = rest.slice(3); + matching = sorted.filter((row) => { + const time = row.createdAt.getTime(); + return ascending + ? time > at || (time === at && row.id > id) + : time < at || (time === at && row.id < id); + }); + } + const limit = Number(rest[0]); + const offset = sql.includes("offset ?") ? Number(rest[1]) : 0; + return { + rows: matching + .slice(offset, offset + limit) + .map((row) => [ + row.id, + "transcription", + row.amountMicroCredits, + 0, + null, + null, + null, + stamp(row.createdAt), + ]), + }; + }); + return { db: () => client }; +}); + +import { LEDGER_PAGE_SIZE } from "@/lib/infra-ledger"; +import { + formatLedgerCursor, + listAppLedger, + listAppLedgerPage, + parseLedgerCursor, +} from "@/lib/infra-ledger-store"; + +const START = Date.parse("2026-11-01T00:00:00Z"); + +const seed = (count: number) => { + state.rows = Array.from({ length: count }, (_, index) => ({ + id: `txn${String(index).padStart(5, "0")}`, + createdAt: new Date(START + Math.floor(index / 3) * 1000), + amountMicroCredits: -(index + 1), + })); +}; + +const newestFirst = () => + [...state.rows] + .sort( + (a, b) => + b.createdAt.getTime() - a.createdAt.getTime() || + b.id.localeCompare(a.id), + ) + .map((row) => row.id); + +const ids = (page: { rows: { id: string }[] }) => + page.rows.map((row) => row.id).sort(); + +const page = (cursor: { before?: string | null; after?: string | null } = {}) => + listAppLedgerPage("app-1", { + limit: LEDGER_PAGE_SIZE, + before: parseLedgerCursor(cursor.before), + after: parseLedgerCursor(cursor.after), + }); + +beforeEach(() => { + state.queries = []; + seed(120); +}); + +describe("ledger pages by cursor", () => { + it("lists the newest page in one query, with no count", async () => { + const first = await page(); + + expect(first.rows).toHaveLength(LEDGER_PAGE_SIZE); + expect(ids(first)).toEqual(newestFirst().slice(0, 50).sort()); + expect(first.newer).toBeNull(); + expect(first.older).not.toBeNull(); + expect(state.queries).toHaveLength(1); + expect(state.queries.join("\n")).not.toMatch(/count\(/i); + expect(state.queries.join("\n")).not.toContain("offset"); + }); + + it("walks every row once going Older, across rows written in the same second", async () => { + const seen: string[] = []; + let current = await page(); + seen.push(...ids(current)); + while (current.older) { + current = await page({ before: current.older }); + seen.push(...ids(current)); + } + + expect(seen).toHaveLength(120); + expect(new Set(seen).size).toBe(120); + expect(current.rows).toHaveLength(20); + expect(current.older).toBeNull(); + expect(state.queries).toHaveLength(3); + }); + + it("returns to the same page going Newer", async () => { + const first = await page(); + const second = await page({ before: first.older }); + const third = await page({ before: second.older }); + + const backToSecond = await page({ after: third.newer }); + const backToFirst = await page({ after: backToSecond.newer }); + + expect(ids(backToSecond)).toEqual(ids(second)); + expect(backToSecond.older).toBe(second.older); + expect(ids(backToFirst)).toEqual(ids(first)); + expect(backToFirst.newer).toBeNull(); + }); + + it("shows a full newest page when fewer than a page of rows is newer", async () => { + const first = await page(); + const second = await page({ before: first.older }); + state.rows.push({ + id: "txn99999", + createdAt: new Date(START + 3_600_000), + amountMicroCredits: -1, + }); + + const anchor = state.rows.find((row) => row.id === "txn00100"); + if (!anchor) throw new Error("seed row missing"); + const afterOne = await page({ after: formatLedgerCursor(anchor) }); + const backToFirst = await page({ after: second.newer }); + const top = await page({ after: backToFirst.newer }); + + expect(afterOne.rows).toHaveLength(LEDGER_PAGE_SIZE); + expect(afterOne.newer).toBeNull(); + expect(backToFirst.rows).toHaveLength(LEDGER_PAGE_SIZE); + expect(backToFirst.newer).not.toBeNull(); + expect(top.rows).toHaveLength(LEDGER_PAGE_SIZE); + expect(top.newer).toBeNull(); + expect(top.rows.some((row) => row.id === "txn99999")).toBe(true); + }); + + it("keeps a cursor that survives a URL and ignores one that is not a cursor", async () => { + const row = { id: "0abc9xyz", createdAt: new Date(START) }; + const cursor = formatLedgerCursor(row); + + expect(cursor).toBe(`${START}.0abc9xyz`); + expect(encodeURIComponent(cursor)).toBe(cursor); + expect(parseLedgerCursor(cursor)).toEqual(row); + for (const bad of [undefined, null, "", "3", "abc.def", `${START}.ABC`]) { + expect(parseLedgerCursor(bad)).toBeNull(); + } + expect(ids(await page({ before: "not-a-cursor" }))).toEqual( + ids(await page()), + ); + }); + + it("shows no page links for a ledger that fits one page", async () => { + seed(7); + + expect(await page()).toMatchObject({ newer: null, older: null }); + expect((await page()).rows).toHaveLength(7); + }); +}); + +describe("the transactions API keeps limit and offset", () => { + it("lists with limit and offset in one query and never counts", async () => { + const result = await listAppLedger("app-1", { limit: 50, offset: 100 }); + + expect(result.rows).toHaveLength(20); + expect(result.hasMore).toBe(false); + expect( + (await listAppLedger("app-1", { limit: 50, offset: 0 })).hasMore, + ).toBe(true); + expect(state.queries).toHaveLength(2); + expect(state.queries[0]).toContain("limit ? offset ?"); + expect(state.queries.join("\n")).not.toMatch(/count\(/i); + }); +}); diff --git a/apps/web/__tests__/unit/infra-ledger.test.ts b/apps/web/__tests__/unit/infra-ledger.test.ts index 601d8591d4c..ed00d305a61 100644 --- a/apps/web/__tests__/unit/infra-ledger.test.ts +++ b/apps/web/__tests__/unit/infra-ledger.test.ts @@ -13,7 +13,6 @@ import { LEDGER_CSV_COLUMNS, type LedgerRow, ledgerCsv, - ledgerPageWindow, orderLedgerRows, serializeLedgerRow, } from "@/lib/infra-ledger"; @@ -546,19 +545,3 @@ describe("CSV export", () => { expect(ledgerCsv([])).toBe(`${LEDGER_CSV_COLUMNS.join(",")}\r\n`); }); }); - -describe("paging the console ledger", () => { - it.each([ - [undefined, 312, { page: 1, offset: 0, from: 1, to: 50, pages: 7 }], - ["2", 312, { page: 2, offset: 50, from: 51, to: 100, pages: 7 }], - ["7", 312, { page: 7, offset: 300, from: 301, to: 312, pages: 7 }], - ["99", 312, { page: 7, offset: 300, from: 301, to: 312, pages: 7 }], - ["0", 312, { page: 1, offset: 0, from: 1, to: 50, pages: 7 }], - ["-3", 312, { page: 1, offset: 0, from: 1, to: 50, pages: 7 }], - ["abc", 312, { page: 1, offset: 0, from: 1, to: 50, pages: 7 }], - ["2", 0, { page: 1, offset: 0, from: 0, to: 0, pages: 1 }], - ["1", 50, { page: 1, offset: 0, from: 1, to: 50, pages: 1 }], - ])("reads page %j of %i rows", (raw, total, expected) => { - expect(ledgerPageWindow(raw, total)).toEqual(expected); - }); -}); diff --git a/apps/web/__tests__/unit/infra-storage-billing.test.ts b/apps/web/__tests__/unit/infra-storage-billing.test.ts index f6ad01f874e..d77dc3305f8 100644 --- a/apps/web/__tests__/unit/infra-storage-billing.test.ts +++ b/apps/web/__tests__/unit/infra-storage-billing.test.ts @@ -29,6 +29,8 @@ import { getInfraAppStanding, getInfraBilling, getInfraUploadAdmission, + latestStorageCharge, + latestStorageCharges, } from "@/lib/infra-standing"; import { getAppStorageUsage } from "@/lib/infra-storage"; import { chargeStorageDay } from "@/lib/infra-storage-billing"; @@ -52,6 +54,20 @@ const charge = async () => now: new Date(ledger.now), }); +const finish = async (day: string) => + chargeStorageDay({ + appId: "app-1", + accountId: "account-1", + usage: await getAppStorageUsage("app-1"), + dateKey: day, + now: new Date(ledger.now), + retryUnpaid: false, + }); + +const later = (hours: number) => { + ledger.now = new Date(ledger.now.getTime() + hours * 60 * 60 * 1000); +}; + const credit = (microCredits: number) => { ledger.account.balanceMicroCredits += microCredits; ledger.rows.push({ @@ -231,6 +247,161 @@ describe("daily storage charge", () => { expect(ledger.snapshots.at(-1)?.microCreditsCharged).toBe(0); }); + it("leaves a billed day alone when it is finished later", async () => { + credit(500_000); + expect(await charge()).toMatchObject({ state: "billed" }); + const snapshot = { ...ledger.snapshots[0] }; + const day = dateKey(); + later(20); + + expect(await finish(day)).toEqual({ state: "skipped" }); + + expect(storageRows()).toHaveLength(1); + expect(ledger.snapshots).toEqual([snapshot]); + expect(ledger.account.balanceMicroCredits).toBe(500_000 - 144); + }); + + it("leaves a short day alone when it is finished later", async () => { + credit(50); + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 50, + fellShort: true, + }); + const snapshot = { ...ledger.snapshots[0] }; + const day = dateKey(); + credit(500_000); + later(20); + + expect(await finish(day)).toEqual({ state: "skipped" }); + + expect(storageRows()).toHaveLength(1); + expect(ledger.snapshots).toEqual([snapshot]); + }); + + it("does not write an unpaid day again when it is finished later, even after a top-up", async () => { + await charge(); + advanceDays(1); + expect(await charge()).toMatchObject({ state: "unpaid" }); + const day = dateKey(); + const recorded = { + ...ledger.snapshots.find((snapshot) => snapshot.snapshotDate === day), + }; + credit(500_000); + later(20); + + expect(await finish(day)).toEqual({ state: "skipped" }); + + expect( + ledger.snapshots.find((snapshot) => snapshot.snapshotDate === day), + ).toEqual(recorded); + expect(storageRows()).toHaveLength(1); + expect(ledger.account.balanceMicroCredits).toBe(500_000); + }); + + it("records an unpaid day once when it is first reached while finishing", async () => { + await charge(); + advanceDays(1); + + expect(await finish(dateKey())).toMatchObject({ state: "unpaid" }); + expect(await finish(dateKey())).toEqual({ state: "skipped" }); + + expect( + ledger.snapshots.filter( + (snapshot) => snapshot.snapshotDate === dateKey(), + ), + ).toHaveLength(1); + }); + + it("charges a day over an idle visit marker and replaces it", async () => { + credit(500_000); + ledger.snapshots = [ + { + snapshotDate: dateKey(), + totalDurationMinutes: 0, + videoCount: 0, + microCreditsCharged: 0, + processedAt: new Date(ledger.now), + }, + ]; + + expect(await charge()).toMatchObject({ + state: "billed", + chargedMicroCredits: 144, + }); + + expect(storageRows()).toHaveLength(1); + expect(ledger.snapshots).toEqual([ + expect.objectContaining({ + snapshotDate: dateKey(), + videoCount: 3, + microCreditsCharged: 144, + }), + ]); + expect(ledger.snapshots[0]?.totalDurationMinutes).toBeGreaterThan(0); + }); + + it("reads the latest billed day, not the last write, when two days are written in the same second", async () => { + const yesterday = new Date(ledger.now.getTime() - DAY_MS) + .toISOString() + .slice(0, 10); + const shortSince = new Date(ledger.now); + ledger.account.balanceMicroCredits = 0; + ledger.rows.push( + { + id: "row-a", + accountId: "account-1", + type: "storage_daily", + amountMicroCredits: -50, + balanceAfterMicroCredits: 0, + referenceId: dateKey(), + referenceType: "storage_day", + metadata: { + snapshotDate: dateKey(), + owedMicroCredits: 144, + chargedMicroCredits: 50, + shortSince: shortSince.toISOString(), + }, + createdAt: new Date(ledger.now), + }, + { + id: "row-b", + accountId: "account-1", + type: "storage_daily", + amountMicroCredits: -144, + balanceAfterMicroCredits: 50, + referenceId: yesterday, + referenceType: "storage_day", + metadata: { + snapshotDate: yesterday, + owedMicroCredits: 144, + chargedMicroCredits: 144, + shortSince: null, + }, + createdAt: new Date(ledger.now), + }, + ); + + expect(await latestStorageCharge(fakeDb as never, "account-1")).toEqual({ + createdAt: new Date(ledger.now), + owedMicroCredits: 144, + shortSince, + }); + expect( + (await latestStorageCharges(fakeDb as never, ["account-1"])).get( + "account-1", + ), + ).toEqual({ + createdAt: new Date(ledger.now), + owedMicroCredits: 144, + shortSince, + }); + expect(await standing()).toMatchObject({ state: "grace" }); + + advanceDays(1); + expect(await charge()).toEqual({ state: "unpaid", shortSince }); + }); + it("does not bill an app that stores nothing", async () => { ledger.videos = []; ledger.account.balanceMicroCredits = 1_000_000; diff --git a/apps/web/__tests__/unit/infra-storage-created-before.test.ts b/apps/web/__tests__/unit/infra-storage-created-before.test.ts new file mode 100644 index 00000000000..65a9f1eb77c --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-created-before.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as { sql: string; params: unknown[] }[], +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(async (sql, params) => { + state.queries.push({ sql, params }); + return { rows: [] }; + }); + return { db: () => client }; +}); + +import { getAppStorage, getStorageUsageByApp } from "@/lib/infra-storage"; + +beforeEach(() => { + state.queries = []; +}); + +describe("stored video bounded by creation time", () => { + it("adds the bound to the same aggregate query", async () => { + const dayEnd = new Date("2026-11-13T00:00:00.000Z"); + + await getStorageUsageByApp(["app-1", "app-2"], undefined, undefined, { + createdBefore: dayEnd, + }); + + expect(state.queries).toHaveLength(1); + const [{ sql, params }] = state.queries as [ + { sql: string; params: unknown[] }, + ]; + expect(sql).toContain("COALESCE(SUM(`duration`), 0)"); + expect(sql).toContain("and `developer_videos`.`createdAt` < ?)"); + expect(sql).toContain("group by"); + expect(params).toEqual(["app-1", "app-2", "2026-11-13 00:00:00.000"]); + }); + + it("reads every stored video when no bound is given", async () => { + await getStorageUsageByApp(["app-1"]); + await getAppStorage("app-1"); + + expect(state.queries).toHaveLength(2); + for (const { sql } of state.queries) { + expect(sql).not.toContain("createdAt"); + } + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts b/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts new file mode 100644 index 00000000000..d2bfa112f1c --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-cron-resume.test.ts @@ -0,0 +1,947 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type ChargeCall = { + accountId: string; + dateKey: string; + retryUnpaid: boolean | undefined; + dailyMicroCredits: number; +}; + +const state = vi.hoisted(() => ({ + accounts: [] as { id: string; appId: string; createdAt: Date }[], + appCreatedAt: new Map(), + videos: new Map(), + markers: new Map(), + markerInserts: [] as string[], + usageReads: [] as { appIds: string[]; createdBefore: Date | undefined }[], + deletedApps: new Set(), + unpaid: new Set(), + short: new Set(), + everShort: new Set(), + charged: new Map(), + snapshotWrites: new Map(), + chargeCalls: [] as ChargeCall[], + accountReads: [] as { cursor: string | null; limit: number | null }[], + accountSql: [] as string[], + longestIdList: 0, + secondsPerCharge: 0, + topUps: vi.fn(), + emails: vi.fn(), + afters: [] as Promise[], + continuations: [] as { url: string; authorization: string | null }[], + continuationStatus: 200 as number | "network", +})); + +vi.mock("next/server", async (original) => ({ + ...(await original()), + after: (task: Promise) => { + state.afters.push(task); + }, +})); +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(async (sql, params) => { + if (sql.includes("from `developer_credit_accounts`")) { + const limit = /limit \?/.test(sql) ? Number(params.at(-1)) : null; + const cursor = /`id` > \?/.test(sql) ? String(params[0]) : null; + state.accountReads.push({ cursor, limit }); + state.accountSql.push(sql); + const after = state.accounts.filter( + (account) => cursor === null || account.id > cursor, + ); + const stamp = (date: Date) => + date.toISOString().slice(0, 19).replace("T", " "); + return { + rows: (limit === null ? after : after.slice(0, limit)).map( + (account) => [account.id, account.appId, stamp(account.createdAt)], + ), + }; + } + if (sql.includes("from `developer_apps`")) { + const bounded = sql.includes("`developer_apps`.`createdAt` < ?"); + const bound = bounded + ? new Date(`${String(params.at(-1)).replace(" ", "T")}Z`) + : null; + return { + rows: (bounded ? params.slice(0, -1) : params) + .map(String) + .filter((appId) => !state.deletedApps.has(appId)) + .filter( + (appId) => + !bound || + (state.appCreatedAt.get(appId) ?? new Date(0)).getTime() < + bound.getTime(), + ) + .map((appId) => [appId]), + }; + } + if (sql.includes("from `developer_credit_transactions`")) { + const ids = params.slice(0, -2).map(String); + const date = String(params.at(-2)); + state.longestIdList = Math.max(state.longestIdList, ids.length); + return { + rows: ids + .filter((id) => state.charged.has(`${id}|${date}`)) + .map((id) => [id]), + }; + } + if (sql.includes("from `developer_daily_storage_snapshots`")) { + const appIds = params.slice(0, -1).map(String); + const date = String(params.at(-1)); + return { + rows: appIds + .filter( + (appId) => + state.snapshotWrites.has(`${appId}|${date}`) || + state.markers.has(`${appId}|${date}`), + ) + .map((appId) => [appId]), + }; + } + throw new Error(`Unexpected query: ${sql}`); + }); + return { db: () => client }; +}); +vi.mock("@/lib/infra-storage", () => ({ + getStorageUsageByApp: async ( + appIds: string[], + _extra?: unknown, + _executor?: unknown, + options: { createdBefore?: Date } = {}, + ) => { + state.usageReads.push({ appIds, createdBefore: options.createdBefore }); + return new Map( + appIds.map((appId) => { + const videos = ( + state.videos.get(appId) ?? [new Date("2026-11-01T00:00:00Z")] + ) + .map((video) => + video instanceof Date + ? { createdAt: video, readyAt: video } + : video, + ) + .filter( + ({ createdAt, readyAt }) => + readyAt.getTime() <= Date.now() && + (!options.createdBefore || + createdAt.getTime() < options.createdBefore.getTime()), + ).length; + return [ + appId, + { + videoCount: videos, + totalMinutes: videos * 10, + minutesByTier: { "1080p": videos * 10, "1440p": 0, "2160p": 0 }, + monthlyMicroCredits: videos * 840, + dailyMicroCredits: videos * 28, + }, + ]; + }), + ); + }, +})); +vi.mock("@/lib/infra-storage-billing", () => ({ + recordStorageDayVisits: async ({ + appIds, + dateKey, + }: { + appIds: string[]; + dateKey: string; + }) => { + for (const appId of appIds) { + const key = `${appId}|${dateKey}`; + state.markerInserts.push(key); + if (!state.snapshotWrites.has(key) && !state.markers.has(key)) { + state.markers.set(key, 1); + } + } + }, + chargeStorageDay: async ({ + appId, + accountId, + dateKey, + retryUnpaid, + usage, + }: { + appId: string; + accountId: string; + dateKey: string; + retryUnpaid?: boolean; + usage: { dailyMicroCredits: number }; + }) => { + state.chargeCalls.push({ + accountId, + dateKey, + retryUnpaid, + dailyMicroCredits: usage.dailyMicroCredits, + }); + vi.setSystemTime(Date.now() + state.secondsPerCharge * 1000); + const key = `${accountId}|${dateKey}`; + const snapshot = `${appId}|${dateKey}`; + if (state.charged.has(key)) return { state: "skipped" }; + const writeSnapshot = () => { + state.markers.delete(snapshot); + state.snapshotWrites.set( + snapshot, + (state.snapshotWrites.get(snapshot) ?? 0) + 1, + ); + }; + if ( + retryUnpaid === false && + (state.markers.has(snapshot) || + (state.unpaid.has(accountId) && state.snapshotWrites.has(snapshot))) + ) { + return { state: "skipped" }; + } + if (state.unpaid.has(accountId)) { + writeSnapshot(); + return { state: "unpaid", shortSince: new Date("2026-11-01T00:00:00Z") }; + } + if (usage.dailyMicroCredits <= 0) return { state: "skipped" }; + state.charged.set(key, 1); + writeSnapshot(); + const short = state.short.has(accountId); + const fellShort = short && !state.everShort.has(accountId); + if (short) state.everShort.add(accountId); + return { + state: "billed", + owedMicroCredits: 28, + chargedMicroCredits: short ? 0 : 28, + balanceMicroCredits: short ? 0 : 972, + shortSince: short ? new Date("2026-11-12T00:00:00Z") : null, + fellShort, + }; + }, +})); +vi.mock("@/lib/infra-standing", () => ({ + INFRA_GRACE_DAYS: 7, + STORAGE_DAY_REFERENCE_TYPE: "storage_day", + latestStorageCharges: async () => new Map(), +})); +vi.mock("@/lib/infra-auto-top-up", () => ({ runDueAutoTopUps: state.topUps })); +vi.mock("@/lib/infra-billing-emails", () => ({ + sendInfraOutOfCreditEmail: state.emails, +})); + +import { + GET, + MAX_STORAGE_HOPS, + maxDuration, +} from "@/app/api/cron/developer-storage/route"; + +const ACCOUNTS = 450; +const ORIGIN = "https://cap.example"; +const PATH = "/api/cron/developer-storage"; + +type Body = { + success?: boolean; + error?: string; + date: string; + complete: boolean; + continued: boolean; + hop: number; + appsProcessed: number; + appsShort: number; +}; + +const call = async (query = "") => { + const response = await GET( + new Request(`${ORIGIN}${PATH}${query}`, { + headers: { authorization: "Bearer cron-secret" }, + }), + ); + return { status: response.status, body: (await response.json()) as Body }; +}; + +const run = async (query = "") => (await call(query)).body; + +const lastContinuation = () => { + const last = state.continuations.at(-1); + if (!last) throw new Error("no continuation was started"); + return new URL(last.url); +}; + +const followChain = async ( + first: Body, + between: () => void = () => {}, + limit = MAX_STORAGE_HOPS + 2, +) => { + const bodies = [first]; + let body = first; + while (body.continued) { + if (bodies.length > limit) throw new Error("chain did not end"); + between(); + const next = lastContinuation(); + body = await run(next.search); + bodies.push(body); + } + return bodies; +}; + +const chargedFor = (date: string) => + [...state.charged.keys()] + .filter((key) => key.endsWith(`|${date}`)) + .map((key) => key.split("|")[0]) + .sort(); + +const callsFor = (date: string) => + state.chargeCalls.filter((call) => call.dateKey === date); + +const allIds = () => state.accounts.map((account) => account.id); + +const settle = (date: string) => { + for (const id of allIds()) state.charged.set(`${id}|${date}`, 1); +}; + +beforeEach(() => { + process.env.CRON_SECRET = "cron-secret"; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-12T06:41:00Z")); + state.accounts = Array.from({ length: ACCOUNTS }, (_, index) => { + const serial = String(index).padStart(4, "0"); + return { + id: `account-${serial}`, + appId: `app-${serial}`, + createdAt: new Date("2026-10-01T00:00:00Z"), + }; + }); + state.appCreatedAt = new Map(); + state.videos = new Map(); + state.markers = new Map(); + state.markerInserts = []; + state.usageReads = []; + state.deletedApps = new Set(); + state.unpaid = new Set(); + state.short = new Set(); + state.everShort = new Set(); + state.charged = new Map(); + state.snapshotWrites = new Map(); + settle("2026-11-11"); + state.chargeCalls = []; + state.accountReads = []; + state.accountSql = []; + state.longestIdList = 0; + state.secondsPerCharge = 0; + state.afters = []; + state.continuations = []; + state.continuationStatus = 200; + state.emails.mockReset(); + state.topUps.mockReset(); + state.topUps.mockResolvedValue({ attempted: 0, succeeded: 0, failed: 0 }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: URL | string, init?: RequestInit) => { + state.continuations.push({ + url: String(url), + authorization: new Headers(init?.headers).get("authorization"), + }); + if (state.continuationStatus === "network") { + throw new TypeError("fetch failed"); + } + return new Response(null, { status: state.continuationStatus }); + }), + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("storage cron paging", () => { + it("reads accounts a page at a time, by id, for yesterday and then today, and never sends every id at once", async () => { + const body = await run(); + + expect(body).toMatchObject({ + date: "2026-11-12", + complete: true, + continued: false, + appsProcessed: ACCOUNTS, + }); + const pages = [ + { cursor: null, limit: 200 }, + { cursor: "account-0199", limit: 200 }, + { cursor: "account-0399", limit: 200 }, + ]; + expect(state.accountReads).toEqual([...pages, ...pages]); + expect(state.longestIdList).toBe(200); + expect(state.continuations).toEqual([]); + expect(callsFor("2026-11-11")).toEqual([]); + }); + + it("pages on the account id alone and leaves out accounts of deleted apps", async () => { + state.deletedApps = new Set(["app-0003", "app-0250"]); + + const body = await run(); + + expect(body).toMatchObject({ complete: true, appsProcessed: ACCOUNTS - 2 }); + expect(chargedFor("2026-11-12")).not.toContain("account-0003"); + expect(chargedFor("2026-11-12")).not.toContain("account-0250"); + expect(state.accountSql.every((sql) => !sql.includes("join"))).toBe(true); + }); +}); + +describe("a cut-off sweep continues itself", () => { + it("hands the date, the last attempted account and the next hop to a request to itself", async () => { + state.secondsPerCharge = 1.6; + const startedAt = Date.now(); + + const body = await run(); + + expect(body).toMatchObject({ + date: "2026-11-12", + complete: false, + continued: true, + hop: 0, + appsProcessed: 130, + }); + expect(Date.now() - startedAt).toBeLessThan(maxDuration * 1000); + expect(state.afters).toHaveLength(1); + await Promise.all(state.afters); + expect(state.continuations).toHaveLength(1); + const next = lastContinuation(); + expect(next.origin).toBe(ORIGIN); + expect(next.pathname).toBe(PATH); + expect(Object.fromEntries(next.searchParams)).toEqual({ + date: "2026-11-12", + after: "account-0129", + hop: "1", + }); + expect(state.continuations[0]?.authorization).toBe("Bearer cron-secret"); + }); + + it("resumes a page cut off part way at the first account it did not attempt", async () => { + state.secondsPerCharge = 1.6; + await run(); + const attempted = state.chargeCalls.map((call) => call.accountId); + state.chargeCalls = []; + state.accountReads = []; + state.secondsPerCharge = 0; + + const body = await run(lastContinuation().search); + + expect(attempted.at(-1)).toBe("account-0129"); + expect(state.chargeCalls[0]?.accountId).toBe("account-0130"); + expect(state.accountReads).toEqual([ + { cursor: "account-0129", limit: 200 }, + { cursor: "account-0329", limit: 200 }, + ]); + expect(body).toMatchObject({ + complete: true, + continued: false, + hop: 1, + appsProcessed: ACCOUNTS - 130, + }); + expect(chargedFor("2026-11-12")).toEqual(allIds()); + }); + + it("never charges again an account an earlier hop charged", async () => { + state.secondsPerCharge = 1.6; + const bodies = await followChain(await run()); + + expect(bodies.length).toBeGreaterThan(2); + expect(bodies.at(-1)).toMatchObject({ complete: true, continued: false }); + const attempted = callsFor("2026-11-12").map((call) => call.accountId); + expect(new Set(attempted).size).toBe(attempted.length); + expect(chargedFor("2026-11-12")).toEqual(allIds()); + expect(bodies.reduce((total, body) => total + body.appsProcessed, 0)).toBe( + ACCOUNTS, + ); + }); + + it("finishes the day a continuation started even after midnight, then sweeps the new day", async () => { + vi.setSystemTime(new Date("2026-11-12T23:56:00Z")); + state.secondsPerCharge = 1.6; + + const first = await run(); + expect(first).toMatchObject({ date: "2026-11-12", continued: true }); + vi.setSystemTime(new Date("2026-11-13T00:02:00Z")); + state.secondsPerCharge = 0; + + const bodies = await followChain(first); + + expect(bodies.at(-1)).toMatchObject({ + date: "2026-11-13", + complete: true, + }); + expect(chargedFor("2026-11-12")).toEqual(allIds()); + expect(chargedFor("2026-11-13")).toEqual(allIds()); + expect(callsFor("2026-11-12")).toHaveLength(ACCOUNTS); + expect(callsFor("2026-11-13")).toHaveLength(ACCOUNTS); + }); + + it("reaches funded accounts behind a run of unpaid accounts longer than one run's budget", async () => { + state.unpaid = new Set(allIds().slice(0, 300)); + state.secondsPerCharge = 1.6; + + const bodies = await followChain(await run()); + + const funded = allIds().slice(300); + expect(chargedFor("2026-11-12")).toEqual(funded); + expect(bodies.at(-1)).toMatchObject({ complete: true }); + const unpaidCalls = callsFor("2026-11-12").filter((call) => + state.unpaid.has(call.accountId), + ); + expect(unpaidCalls).toHaveLength(300); + expect(new Set(unpaidCalls.map((call) => call.accountId)).size).toBe(300); + }); +}); + +describe("a broken chain is finished by the next day's first run", () => { + const breakOnLastTickOfTheDay = async () => { + vi.setSystemTime(new Date("2026-11-12T18:41:00Z")); + state.secondsPerCharge = 1.6; + state.continuationStatus = "network"; + vi.spyOn(console, "error").mockImplementation(() => {}); + const lastTick = await run(); + await Promise.all(state.afters); + expect(lastTick).toMatchObject({ + date: "2026-11-12", + complete: false, + continued: true, + }); + expect(console.error).toHaveBeenCalledWith( + "[developer-storage] Continuation not started", + expect.objectContaining({ date: "2026-11-12", after: "account-0129" }), + ); + const firstDayCalls = callsFor("2026-11-12").map((call) => call.accountId); + vi.setSystemTime(new Date("2026-11-13T00:41:00Z")); + state.secondsPerCharge = 0; + state.continuationStatus = 200; + state.chargeCalls = []; + return firstDayCalls; + }; + + it("charges the accounts after the cursor for that day, each once, then sweeps today", async () => { + const chargedBeforeTheBreak = await breakOnLastTickOfTheDay(); + + const nextDay = await run(); + + expect(nextDay).toMatchObject({ + date: "2026-11-13", + complete: true, + continued: false, + hop: 0, + appsProcessed: ACCOUNTS - 130 + ACCOUNTS, + }); + expect(chargedFor("2026-11-12")).toEqual(allIds()); + expect(chargedFor("2026-11-13")).toEqual(allIds()); + const finished = callsFor("2026-11-12").map((call) => call.accountId); + expect(finished).toEqual(allIds().slice(130)); + expect(finished.some((id) => chargedBeforeTheBreak.includes(id))).toBe( + false, + ); + expect( + callsFor("2026-11-12").every((call) => call.retryUnpaid === false), + ).toBe(true); + expect( + callsFor("2026-11-13").every((call) => call.retryUnpaid === true), + ).toBe(true); + }); + + it("does not charge or email again an account that fell short before the break", async () => { + state.short = new Set(["account-0005", "account-0006", "account-0200"]); + await breakOnLastTickOfTheDay(); + expect(state.emails).toHaveBeenCalledTimes(2); + + await run(); + + expect(state.emails).toHaveBeenCalledTimes(3); + const emailed = state.emails.mock.calls.map( + ([notice]) => (notice as { accountId: string }).accountId, + ); + expect(emailed.sort()).toEqual([ + "account-0005", + "account-0006", + "account-0200", + ]); + expect(callsFor("2026-11-12").map((call) => call.accountId)).not.toContain( + "account-0005", + ); + }); + + it("writes no second snapshot for an account that was unpaid before the break", async () => { + state.unpaid = new Set([ + "account-0010", + "account-0011", + "account-0300", + "account-0301", + ]); + await breakOnLastTickOfTheDay(); + expect(state.snapshotWrites.get("app-0010|2026-11-12")).toBe(1); + expect(state.snapshotWrites.has("app-0300|2026-11-12")).toBe(false); + + await run(); + + const finished = callsFor("2026-11-12").map((call) => call.accountId); + expect(finished).not.toContain("account-0010"); + expect(finished).not.toContain("account-0011"); + expect(finished).toContain("account-0300"); + for (const app of ["app-0010", "app-0011", "app-0300", "app-0301"]) { + expect(state.snapshotWrites.get(`${app}|2026-11-12`)).toBe(1); + } + expect(state.snapshotWrites.get("app-0010|2026-11-13")).toBe(1); + }); + + it("costs a settled yesterday one read per table per page and no charge", async () => { + settle("2026-11-12"); + vi.setSystemTime(new Date("2026-11-13T00:41:00Z")); + state.unpaid = new Set(allIds().slice(0, 50)); + for (const id of allIds().slice(0, 50)) { + state.charged.delete(`${id}|2026-11-12`); + state.snapshotWrites.set(`${id.replace("account", "app")}|2026-11-12`, 1); + } + + await run(); + + expect(callsFor("2026-11-12")).toEqual([]); + for (const id of allIds().slice(0, 50)) { + expect( + state.snapshotWrites.get(`${id.replace("account", "app")}|2026-11-12`), + ).toBe(1); + } + }); +}); + +describe("the finishing pass counts only what existed on that day", () => { + const nextDayTick = () => { + vi.setSystemTime(new Date("2026-11-13T00:41:00Z")); + settle("2026-11-12"); + for (const id of [ + "account-0300", + "account-0301", + "account-0302", + "account-0303", + ]) { + state.charged.delete(`${id}|2026-11-12`); + } + }; + const account = (id: string) => { + const found = state.accounts.find((row) => row.id === id); + if (!found) throw new Error(`no ${id}`); + return found; + }; + + it("does not charge yesterday for an account whose first video arrived after midnight", async () => { + nextDayTick(); + state.videos.set("app-0300", [new Date("2026-11-13T00:30:00Z")]); + + await run(); + + expect(chargedFor("2026-11-12")).not.toContain("account-0300"); + expect(callsFor("2026-11-12").map((call) => call.accountId)).not.toContain( + "account-0300", + ); + expect(chargedFor("2026-11-13")).toContain("account-0300"); + }); + + it("does not charge yesterday for an account or app created after midnight", async () => { + nextDayTick(); + account("account-0301").createdAt = new Date("2026-11-13T00:10:00Z"); + state.appCreatedAt.set("app-0302", new Date("2026-11-13T00:10:00Z")); + + await run(); + + const finished = callsFor("2026-11-12").map((call) => call.accountId); + expect(finished).not.toContain("account-0301"); + expect(finished).not.toContain("account-0302"); + expect(finished.sort()).toEqual(["account-0300", "account-0303"]); + expect(chargedFor("2026-11-13")).toEqual( + expect.arrayContaining(["account-0301", "account-0302"]), + ); + }); + + it("leaves a video created after midnight out of yesterday's charge and keeps the earlier ones", async () => { + nextDayTick(); + state.videos.set("app-0303", [ + new Date("2026-11-05T00:00:00Z"), + new Date("2026-11-12T23:59:59Z"), + new Date("2026-11-13T00:20:00Z"), + ]); + + await run(); + + const [yesterday] = callsFor("2026-11-12").filter( + (call) => call.accountId === "account-0303", + ); + const [today] = callsFor("2026-11-13").filter( + (call) => call.accountId === "account-0303", + ); + expect(yesterday?.dailyMicroCredits).toBe(56); + expect(today?.dailyMicroCredits).toBe(84); + }); + + it("bounds only the finishing pass, at the midnight after that day", async () => { + nextDayTick(); + + await run(); + + const bounds = state.usageReads.map((read) => + read.createdBefore?.toISOString(), + ); + expect(bounds.filter(Boolean)).toEqual(["2026-11-13T00:00:00.000Z"]); + expect(bounds.filter((bound) => bound === undefined)).toHaveLength(3); + }); +}); + +describe("a visit on a day leaves a marker when nothing is charged", () => { + const tick = (at: string) => { + vi.setSystemTime(new Date(at)); + return run(); + }; + const insertsFor = (app: string, date: string) => + state.markerInserts.filter((key) => key === `${app}|${date}`).length; + + it("does not charge a day for an app that was empty at every run of it, when its first video becomes ready after midnight", async () => { + state.videos.set("app-0300", [ + { + createdAt: new Date("2026-11-12T23:00:00Z"), + readyAt: new Date("2026-11-13T00:20:00Z"), + }, + ]); + for (const at of [ + "2026-11-12T06:41:00Z", + "2026-11-12T12:41:00Z", + "2026-11-12T18:41:00Z", + ]) { + await tick(at); + } + expect(state.markers.has("app-0300|2026-11-12")).toBe(true); + + await tick("2026-11-13T00:41:00Z"); + + expect(callsFor("2026-11-12").map((call) => call.accountId)).not.toContain( + "account-0300", + ); + expect(chargedFor("2026-11-12")).not.toContain("account-0300"); + expect(chargedFor("2026-11-13")).toContain("account-0300"); + }); + + it("charges a video that becomes ready after the last run of its day from the next day, as main does", async () => { + state.videos.set("app-0300", [ + { + createdAt: new Date("2026-11-12T19:00:00Z"), + readyAt: new Date("2026-11-12T19:05:00Z"), + }, + ]); + for (const at of [ + "2026-11-12T06:41:00Z", + "2026-11-12T12:41:00Z", + "2026-11-12T18:41:00Z", + ]) { + await tick(at); + } + expect(state.markers.has("app-0300|2026-11-12")).toBe(true); + + await tick("2026-11-13T00:41:00Z"); + await tick("2026-11-13T06:41:00Z"); + + expect(callsFor("2026-11-12").map((call) => call.accountId)).not.toContain( + "account-0300", + ); + expect(chargedFor("2026-11-12")).not.toContain("account-0300"); + expect( + callsFor("2026-11-13").filter( + (call) => call.accountId === "account-0300", + ), + ).toEqual([expect.objectContaining({ dailyMicroCredits: 28 })]); + expect(chargedFor("2026-11-13")).toContain("account-0300"); + }); + + it("charges a day for an app the sweep never reached that day, for videos created before midnight", async () => { + state.videos.set("app-0300", [ + new Date("2026-11-12T10:00:00Z"), + { + createdAt: new Date("2026-11-13T00:05:00Z"), + readyAt: new Date("2026-11-13T00:06:00Z"), + }, + ]); + vi.setSystemTime(new Date("2026-11-12T18:41:00Z")); + state.secondsPerCharge = 1.6; + state.continuationStatus = "network"; + vi.spyOn(console, "error").mockImplementation(() => {}); + const lastTick = await run(); + await Promise.all(state.afters); + expect(lastTick.continued).toBe(true); + expect(state.markers.has("app-0300|2026-11-12")).toBe(false); + state.secondsPerCharge = 0; + + await tick("2026-11-13T00:41:00Z"); + + const [finished] = callsFor("2026-11-12").filter( + (call) => call.accountId === "account-0300", + ); + expect(finished?.dailyMicroCredits).toBe(28); + expect(chargedFor("2026-11-12")).toContain("account-0300"); + const [today] = callsFor("2026-11-13").filter( + (call) => call.accountId === "account-0300", + ); + expect(today?.dailyMicroCredits).toBe(56); + }); + + it("writes one marker per idle app and day, however often the app is visited", async () => { + const idle = ["app-0400", "app-0401", "app-0402", "app-0403", "app-0404"]; + for (const app of idle) state.videos.set(app, []); + + for (const at of [ + "2026-11-12T06:41:00Z", + "2026-11-12T12:41:00Z", + "2026-11-12T18:41:00Z", + "2026-11-13T00:41:00Z", + "2026-11-13T06:41:00Z", + ]) { + await tick(at); + } + + for (const app of idle) { + expect(insertsFor(app, "2026-11-12")).toBe(1); + expect(insertsFor(app, "2026-11-13")).toBe(1); + expect(state.markers.get(`${app}|2026-11-12`)).toBe(1); + } + expect(state.markerInserts).toHaveLength(idle.length * 2); + expect( + state.chargeCalls.filter((call) => + idle.includes(call.accountId.replace("account", "app")), + ), + ).toEqual([]); + }); + + it("still charges the same day when storage arrives after an idle marker", async () => { + state.videos.set("app-0410", [ + { + createdAt: new Date("2026-11-12T09:00:00Z"), + readyAt: new Date("2026-11-12T10:00:00Z"), + }, + ]); + await tick("2026-11-12T06:41:00Z"); + expect(state.markers.has("app-0410|2026-11-12")).toBe(true); + expect(chargedFor("2026-11-12")).not.toContain("account-0410"); + + await tick("2026-11-12T12:41:00Z"); + + const charged = callsFor("2026-11-12").filter( + (call) => call.accountId === "account-0410", + ); + expect(charged).toEqual([ + expect.objectContaining({ dailyMicroCredits: 28, retryUnpaid: true }), + ]); + expect(chargedFor("2026-11-12")).toContain("account-0410"); + expect(state.snapshotWrites.get("app-0410|2026-11-12")).toBe(1); + expect(state.markers.has("app-0410|2026-11-12")).toBe(false); + }); +}); + +describe("the chain is bounded", () => { + it("stops at the hop limit and logs it", async () => { + state.secondsPerCharge = 1.6; + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + + const body = await run( + `?date=2026-11-12&after=account-0099&hop=${MAX_STORAGE_HOPS}`, + ); + + expect(body).toMatchObject({ + complete: false, + continued: false, + hop: MAX_STORAGE_HOPS, + }); + expect(state.afters).toEqual([]); + expect(state.continuations).toEqual([]); + expect(errors).toHaveBeenCalledWith( + "[developer-storage] Sweep stopped at the hop limit", + expect.objectContaining({ date: "2026-11-12", hop: MAX_STORAGE_HOPS }), + ); + }); + + it("logs a continuation the route refused", async () => { + state.secondsPerCharge = 1.6; + state.continuationStatus = 500; + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + + await run(); + await Promise.all(state.afters); + + expect(errors).toHaveBeenCalledWith( + "[developer-storage] Continuation refused", + expect.objectContaining({ status: 500, hop: 1 }), + ); + }); + + it("logs a handoff that is still running, with the next cursor, as information", async () => { + state.secondsPerCharge = 1.6; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new DOMException("The operation timed out.", "TimeoutError"); + }), + ); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + + await run(); + await Promise.all(state.afters); + + expect(info).toHaveBeenCalledWith( + "[developer-storage] Continuation still running at handoff", + { date: "2026-11-12", after: "account-0129", hop: 1 }, + ); + expect(errors).not.toHaveBeenCalled(); + }); + + it("refuses a date other than today or yesterday and any malformed continuation", async () => { + for (const query of [ + "?date=2026-11-10&hop=1", + "?date=2026-11-13&hop=1", + "?date=2026-1-12&hop=1", + "?date=yesterday&hop=1", + "?date=2026-11-12", + "?hop=1", + "?after=account-0001", + "?date=2026-11-12&hop=0", + `?date=2026-11-12&hop=${MAX_STORAGE_HOPS + 1}`, + "?date=2026-11-12&hop=1&after=a%20b", + "?date=2026-11-12&hop=1x", + ]) { + const { status, body } = await call(query); + expect({ query, status, error: body.error }).toEqual({ + query, + status: 400, + error: "Invalid continuation", + }); + } + expect(state.chargeCalls).toEqual([]); + expect(state.topUps).not.toHaveBeenCalled(); + expect((await call("?date=2026-11-11&hop=1")).status).toBe(200); + }); +}); + +describe("auto top-ups run once per sweep", () => { + it("runs before charging on the scheduled run and after charging on the last hop only", async () => { + state.secondsPerCharge = 1.6; + const startedAt = Date.now(); + + const first = await run(); + expect(state.topUps).toHaveBeenCalledTimes(1); + const [[, before]] = state.topUps.mock.calls as [[Date, { until: number }]]; + expect(before.until - startedAt).toBeGreaterThan(0); + expect(before.until - startedAt).toBeLessThan(maxDuration * 1000); + + state.topUps.mockClear(); + const second = await run(lastContinuation().search); + expect(second.continued).toBe(true); + expect(state.topUps).not.toHaveBeenCalled(); + + state.secondsPerCharge = 0; + const bodies = await followChain(second); + expect(bodies.at(-1)).toMatchObject({ complete: true }); + expect(state.topUps).toHaveBeenCalledTimes(1); + expect(first.continued).toBe(true); + }); + + it("runs both passes on a scheduled run that finishes", async () => { + await run(); + + expect(state.topUps).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-latest-day.test.ts b/apps/web/__tests__/unit/infra-storage-latest-day.test.ts new file mode 100644 index 00000000000..43fb75e8f30 --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-latest-day.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as { sql: string; params: unknown[] }[], +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(async (sql, params) => { + state.queries.push({ sql, params }); + return { rows: [] }; + }); + return { db: () => client }; +}); + +import { db } from "@cap/database"; +import { + latestStorageCharge, + latestStorageCharges, +} from "@/lib/infra-standing"; + +const LATEST_DAY = + "(select `developer_credit_transactions`.`accountId`, `developer_credit_transactions`.`referenceId` from `developer_credit_transactions` where `developer_credit_transactions`.`accountId` = ? and `developer_credit_transactions`.`referenceType` = ? and `developer_credit_transactions`.`referenceId` <= ? order by `developer_credit_transactions`.`referenceId` desc limit 1)"; + +beforeEach(() => { + state.queries = []; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-11-13T23:59:59Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("latest storage day", () => { + it("orders one account's storage day rows by the day they bill, up to tomorrow", async () => { + await latestStorageCharge(db() as never, "account-1"); + + expect(state.queries).toHaveLength(1); + const [{ sql, params }] = state.queries as [ + { sql: string; params: unknown[] }, + ]; + expect(sql).toBe( + "select `createdAt`, `metadata` from `developer_credit_transactions` where (`developer_credit_transactions`.`accountId` = ? and (`developer_credit_transactions`.`type` = ? and `developer_credit_transactions`.`referenceType` = ?) and `developer_credit_transactions`.`referenceId` <= ?) order by `developer_credit_transactions`.`referenceId` desc, `developer_credit_transactions`.`id` desc limit ?", + ); + expect(params).toEqual([ + "account-1", + "storage_daily", + "storage_day", + "2026-11-14", + 1, + ]); + }); + + it("finds every account's latest day in one statement, one bounded lookup per account", async () => { + await latestStorageCharges(db() as never, ["account-1", "account-2"]); + + expect(state.queries).toHaveLength(1); + const [{ sql, params }] = state.queries as [ + { sql: string; params: unknown[] }, + ]; + expect(sql).toContain( + `in (select latest.accountId, latest.referenceId from (${LATEST_DAY} union all ${LATEST_DAY}) latest)`, + ); + expect(params).toEqual([ + "storage_daily", + "storage_day", + "account-1", + "storage_day", + "2026-11-14", + "account-2", + "storage_day", + "2026-11-14", + ]); + }); + + it("reads nothing for no accounts", async () => { + expect(await latestStorageCharges(db() as never, [])).toEqual(new Map()); + expect(state.queries).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-once.test.ts b/apps/web/__tests__/unit/infra-storage-once.test.ts new file mode 100644 index 00000000000..625a12a4a2d --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-once.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const holder = vi.hoisted(() => ({ db: null as unknown })); + +vi.mock("@cap/database", () => ({ db: () => holder.db })); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => "row", + nanoIdLength: 15, +})); + +import { fakeDb, ledger, resetLedger } from "./infra-billing-fake-db"; + +holder.db = fakeDb; + +import { billableVideo } from "@/lib/developer-pricing"; +import { getInfraBilling, infraBillingJson } from "@/lib/infra-standing"; +import { getAppStorage, getAppStorageUsage } from "@/lib/infra-storage"; + +const newVideo = billableVideo({ + duration: 900, + width: 3840, + height: 2160, + maxResolutionTier: "2160p", +}); + +const videoReads = (select: { mock: { results: { value: unknown }[] } }) => + select.mock.results.length; + +beforeEach(() => { + resetLedger(); + ledger.account.balanceMicroCredits = 40_000; + ledger.videos = [ + { + videoId: "v1", + appId: "app-1", + width: 1280, + height: 720, + seconds: 61.5, + count: 3, + }, + { + videoId: "v2", + appId: "app-1", + width: 2560, + height: 1440, + seconds: 600.25, + count: 2, + }, + { + videoId: "v3", + appId: "app-1", + width: 3840, + height: 2160, + seconds: 1234.125, + count: 1, + maxResolutionTier: "1080p", + }, + { + videoId: "v4", + appId: "app-1", + width: 1920, + height: 1080, + seconds: 300, + count: 4, + status: "processing", + }, + { + videoId: "v5", + appId: "app-1", + width: 1920, + height: 1080, + seconds: 77, + count: 1, + status: "error", + }, + ]; +}); + +describe("one read of stored video serves both totals", () => { + it("gives the same stored and counted totals as two separate reads", async () => { + for (const [video, options] of [ + [undefined, {}], + [newVideo, {}], + [newVideo, { excludeVideoId: "v2" }], + [undefined, { excludeVideoId: "v4" }], + ] as const) { + const storage = await getAppStorage("app-1", video, undefined, options); + + expect(storage.stored).toEqual( + await getAppStorageUsage("app-1", video, undefined, options), + ); + expect(storage.counted).toEqual( + await getAppStorageUsage("app-1", video, undefined, { + ...options, + includeProcessing: true, + }), + ); + } + }); + + it("keeps processing video out of what is shown as stored and in what admission counts", async () => { + const { stored, counted } = await getAppStorage("app-1"); + + expect(stored.videoCount).toBe(6); + expect(counted.videoCount).toBe(10); + expect(counted.totalMinutes - stored.totalMinutes).toBeCloseTo(5, 10); + }); + + it("reads video once for billing and returns what the pages show", async () => { + const now = new Date(ledger.now); + const select = vi.spyOn(fakeDb, "select"); + const onVideos = () => + select.mock.calls.filter(([fields]) => "seconds" in fields).length; + + const billing = await getInfraBilling("app-1", undefined, now); + + expect(onVideos()).toBe(1); + expect(videoReads(select)).toBeGreaterThan(1); + select.mockRestore(); + expect(billing.stored).toEqual(await getAppStorageUsage("app-1")); + expect(billing.usage).toEqual( + await getAppStorageUsage("app-1", undefined, undefined, { + includeProcessing: true, + }), + ); + expect(infraBillingJson(billing).storage.minutesByTier).toEqual( + billing.usage.minutesByTier, + ); + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-snapshot-readers.test.ts b/apps/web/__tests__/unit/infra-storage-snapshot-readers.test.ts new file mode 100644 index 00000000000..b52178485df --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-snapshot-readers.test.ts @@ -0,0 +1,149 @@ +import { User } from "@cap/web-domain"; +import { Effect } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Snapshot = { + snapshotDate: string; + totalDurationMinutes: number; + videoCount: number; + microCreditsCharged: number; +}; + +const state = vi.hoisted(() => ({ + queries: [] as string[], + snapshots: [] as { + snapshotDate: string; + totalDurationMinutes: number; + videoCount: number; + microCreditsCharged: number; + }[], +})); + +vi.mock("server-only", () => ({})); +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const STAMP = "2026-11-01 00:00:00"; + const client = drizzle(async (sql, params) => { + state.queries.push(sql); + if (sql.includes("from `developer_daily_storage_snapshots`")) { + const skipsZero = sql.includes( + "`developer_daily_storage_snapshots`.`totalDurationMinutes` > ?", + ); + const since = params.find( + (value) => + typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value), + ); + const rows = state.snapshots + .filter((row) => !skipsZero || row.totalDurationMinutes > 0) + .filter((row) => !since || row.snapshotDate >= String(since)) + .sort((a, b) => b.snapshotDate.localeCompare(a.snapshotDate)); + if ( + sql.startsWith( + "select `developer_daily_storage_snapshots`.`totalDurationMinutes`", + ) || + sql.startsWith("select `totalDurationMinutes` from") + ) { + return { + rows: rows.slice(0, 1).map((row) => [row.totalDurationMinutes]), + }; + } + return { + rows: rows.map((row) => [ + row.snapshotDate, + row.totalDurationMinutes, + row.videoCount, + row.microCreditsCharged, + ]), + }; + } + if (sql.includes("from `developer_apps`")) { + return { + rows: [["app-1", "First app", "production", null, STAMP, STAMP]], + }; + } + if (sql.includes("from `developer_credit_accounts`")) { + return { rows: [[5_000_000, 0, 0, 0, 0]] }; + } + if (sql.includes("COUNT(*)")) return { rows: [[3]] }; + return { rows: [] }; + }); + return { db: () => client }; +}); + +import { getInfraStorageSnapshots } from "@/app/(infra)/dashboard/infra/infra-data"; +import { summarizeStorageMonth } from "@/lib/infra-usage"; +import { AgentManagement } from "../../../../packages/web-backend/src/AgentManagement"; + +const charged = (snapshotDate: string, minutes: number): Snapshot => ({ + snapshotDate, + totalDurationMinutes: minutes, + videoCount: 2, + microCreditsCharged: 120, +}); +const unpaid = (snapshotDate: string): Snapshot => ({ + snapshotDate, + totalDurationMinutes: 30, + videoCount: 2, + microCreditsCharged: 0, +}); +const marker = (snapshotDate: string): Snapshot => ({ + snapshotDate, + totalDurationMinutes: 0, + videoCount: 0, + microCreditsCharged: 0, +}); + +const appContext = () => + Effect.runPromise( + Effect.gen(function* () { + const agents = yield* AgentManagement; + return yield* agents.getDeveloperAppContext( + User.UserId.make("user-1"), + "app-1", + ); + }).pipe(Effect.provide(AgentManagement.Default)), + ); + +beforeEach(() => { + state.queries = []; + state.snapshots = [ + charged("2026-11-09", 40), + unpaid("2026-11-10"), + charged("2026-11-11", 45), + ]; +}); + +describe("readers of daily storage snapshots ignore visit markers", () => { + it("the console usage history and month summary are the same with markers on other days", async () => { + const now = new Date("2026-11-13T12:00:00Z"); + const before = await getInfraStorageSnapshots("app-1", "2026-10-01"); + state.snapshots.push(marker("2026-11-12"), marker("2026-11-13")); + + const after = await getInfraStorageSnapshots("app-1", "2026-10-01"); + + expect(after).toEqual(before); + expect(after.map((row) => row.snapshotDate)).toEqual([ + "2026-11-11", + "2026-11-10", + "2026-11-09", + ]); + expect(summarizeStorageMonth(after, now)).toEqual( + summarizeStorageMonth(before, now), + ); + expect(summarizeStorageMonth(after, now)).toEqual({ + chargedMicroCredits: 240, + billedDays: 3, + }); + }); + + it("the agent API reports the last stored minutes, not a later marker", async () => { + const before = await appContext(); + state.snapshots.push(marker("2026-11-12")); + + const after = await appContext(); + + expect(before.storageMinutes).toBe(45); + expect(after.storageMinutes).toBe(45); + expect(after).toEqual(before); + }); +}); diff --git a/apps/web/__tests__/unit/infra-storage-visit-marker.test.ts b/apps/web/__tests__/unit/infra-storage-visit-marker.test.ts new file mode 100644 index 00000000000..0b0ac5ff01f --- /dev/null +++ b/apps/web/__tests__/unit/infra-storage-visit-marker.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + queries: [] as { sql: string; params: unknown[] }[], +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(async (sql, params) => { + state.queries.push({ sql, params }); + return { rows: [{ affectedRows: 0, insertId: 0 }] }; + }); + return { db: () => client }; +}); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => "marker-id", + nanoIdLength: 15, +})); + +import { recordStorageDayVisits } from "@/lib/infra-storage-billing"; + +beforeEach(() => { + state.queries = []; +}); + +describe("storage day visit markers", () => { + it("writes every visited app of a page in one statement and changes nothing that exists", async () => { + await recordStorageDayVisits({ + appIds: ["app-1", "app-2"], + dateKey: "2026-11-12", + now: new Date("2026-11-12T06:41:00Z"), + }); + + expect(state.queries).toHaveLength(1); + const [{ sql, params }] = state.queries as [ + { sql: string; params: unknown[] }, + ]; + expect(sql).toMatch( + /^insert into `developer_daily_storage_snapshots` .* values \(.*\), \(.*\) on duplicate key update `appId` = `developer_daily_storage_snapshots`\.`appId`$/, + ); + expect(params).toEqual([ + "marker-id", + "app-1", + "2026-11-12", + 0, + 0, + 0, + "2026-11-12 06:41:00.000", + "marker-id", + "app-2", + "2026-11-12", + 0, + 0, + 0, + "2026-11-12 06:41:00.000", + ]); + }); + + it("does nothing for a page with no idle visits", async () => { + await recordStorageDayVisits({ + appIds: [], + dateKey: "2026-11-12", + now: new Date("2026-11-12T06:41:00Z"), + }); + + expect(state.queries).toEqual([]); + }); +}); diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx index d72659cb07b..581a37e2555 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/billing-view.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import Link from "next/link"; -import type { LedgerRow, ledgerPageWindow } from "@/lib/infra-ledger"; +import type { LedgerRow } from "@/lib/infra-ledger"; import { INFRA_RESOLUTION_TIERS, INFRA_SPEECH_DOLLARS_PER_HOUR, @@ -66,7 +66,6 @@ export function BillingView({ shortfallMicroCredits, autoTopUpFailure, transactions, - totalTransactions, ledgerPage, }: { app: { id: string; name: string }; @@ -76,12 +75,12 @@ export function BillingView({ shortfallMicroCredits: number; autoTopUpFailure: { day: string; reason: string } | null; transactions: LedgerRow[]; - totalTransactions: number; - ledgerPage: ReturnType; + ledgerPage: { newer: string | null; older: string | null }; }) { const billingPath = infraAppPath(app.id, "billing"); - const ledgerHref = (page: number) => - `${billingPath}${page > 1 ? `?page=${page}` : ""}#${LEDGER_ANCHOR}`; + const ledgerHref = (direction: "before" | "after", cursor: string) => + `${billingPath}?${direction}=${encodeURIComponent(cursor)}#${LEDGER_ANCHOR}`; + const hasTransactions = transactions.length > 0 || ledgerPage.newer !== null; return ( <> @@ -207,7 +206,7 @@ export function BillingView({ title="Transactions" description="Every credit and charge, newest first. Times are in UTC." action={ - totalTransactions > 0 && ( + hasTransactions && ( - {ledgerPage.pages > 1 && ( + {(ledgerPage.newer || ledgerPage.older) && ( )} diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/page.tsx index 2e7c2fc833c..6917127d2f1 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/billing/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/billing/page.tsx @@ -3,8 +3,12 @@ import { autoTopUpFailureText, getAutoTopUpFailure, } from "@/lib/infra-auto-top-up"; -import { LEDGER_PAGE_SIZE, ledgerPageWindow } from "@/lib/infra-ledger"; -import { countAppLedger, listAppLedger } from "@/lib/infra-ledger-store"; +import { LEDGER_PAGE_SIZE } from "@/lib/infra-ledger"; +import { + type LedgerPage, + listAppLedgerPage, + parseLedgerCursor, +} from "@/lib/infra-ledger-store"; import { getInfraBilling } from "@/lib/infra-standing"; import { getInfraProCredit, requireInfraApp } from "../../infra-data"; import { BillingView } from "./billing-view"; @@ -13,6 +17,11 @@ export const metadata: Metadata = { title: "Billing | Cap Infra", }; +const EMPTY_LEDGER: LedgerPage = { rows: [], newer: null, older: null }; + +const first = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + const failureDay = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", @@ -24,30 +33,26 @@ export default async function InfraBillingPage({ searchParams, }: { params: Promise<{ appId: string }>; - searchParams: Promise<{ page?: string | string[] }>; + searchParams: Promise<{ + before?: string | string[]; + after?: string | string[]; + }>; }) { const [{ appId }, query] = await Promise.all([params, searchParams]); const { app } = await requireInfraApp(appId); const account = app.creditAccount; - const [total, proCredit, billing, failure] = await Promise.all([ - account ? countAppLedger(app.id) : Promise.resolve(0), + const [ledger, proCredit, billing, failure] = await Promise.all([ + account + ? listAppLedgerPage(app.id, { + limit: LEDGER_PAGE_SIZE, + before: parseLedgerCursor(first(query.before)), + after: parseLedgerCursor(first(query.after)), + }) + : Promise.resolve(EMPTY_LEDGER), getInfraProCredit(), getInfraBilling(app.id), account ? getAutoTopUpFailure(account.id) : Promise.resolve(null), ]); - const ledgerPage = ledgerPageWindow( - Array.isArray(query.page) ? query.page[0] : query.page, - total, - ); - const transactions = - total > 0 - ? ( - await listAppLedger(app.id, { - limit: LEDGER_PAGE_SIZE, - offset: ledgerPage.offset, - }) - ).rows - : []; return ( ); } diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/keys/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/keys/page.tsx index 29f111b4628..e9fda712863 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/keys/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/keys/page.tsx @@ -1,4 +1,8 @@ -import { infraPageMetadata, requireInfraApp } from "../../infra-data"; +import { + getInfraApiKeys, + infraPageMetadata, + requireInfraApp, +} from "../../infra-data"; import { ApiKeysClient } from "./api-keys-client"; export const generateMetadata = infraPageMetadata("API keys"); @@ -10,7 +14,8 @@ export default async function InfraKeysPage({ }) { const { appId } = await params; const { app } = await requireInfraApp(appId); - const secretKey = app.apiKeys.find((k) => k.keyType === "secret"); + const apiKeys = await getInfraApiKeys(app.id); + const secretKey = apiKeys.find((k) => k.keyType === "secret"); return ( - + {children} diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx index a08cce5fc5a..a4c775b6641 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/page.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import Link from "next/link"; import { infraAppPath } from "@/lib/infra-routes"; import { getInfraBilling } from "@/lib/infra-standing"; -import { getAppStorageUsage } from "@/lib/infra-storage"; import { formatCharge, formatCredits, @@ -28,6 +27,7 @@ import { UploadDrop } from "../_components/upload-drop"; import { LiveVideoStatus, WatchVideos } from "../_components/video-live"; import { getDeveloperAppVideos, + getInfraApiKeys, getInfraProCredit, getInfraStorageChargesSince, infraPageMetadata, @@ -56,19 +56,20 @@ export default async function InfraOverviewPage({ const { app } = await requireInfraApp(appId); const monthStart = utcMonthStart(new Date()); - const [usage, monthStorageCharges, recentVideos, proCredit, billing] = + const [monthStorageCharges, recentVideos, proCredit, billing, apiKeys] = await Promise.all([ - getAppStorageUsage(app.id), app.creditAccount ? getInfraStorageChargesSince(app.creditAccount.id, monthStart) : Promise.resolve(0), getDeveloperAppVideos(app.id, { limit: 6 }), getInfraProCredit(), getInfraBilling(app.id), + getInfraApiKeys(app.id), ]); + const usage = billing.stored; const balance = app.creditAccount?.balanceMicroCredits ?? 0; - const secretKey = app.apiKeys.find((k) => k.keyType === "secret"); + const secretKey = apiKeys.find((k) => k.keyType === "secret"); const empty = recentVideos.length === 0; return ( diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx index 0a5d7181ce6..940302a0409 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx @@ -1,4 +1,8 @@ -import { infraPageMetadata, requireInfraApp } from "../../infra-data"; +import { + getInfraVideoCount, + infraPageMetadata, + requireInfraApp, +} from "../../infra-data"; import { AppSettingsClient } from "./app-settings-client"; export const generateMetadata = infraPageMetadata("Settings"); @@ -10,6 +14,7 @@ export default async function InfraSettingsPage({ }) { const { appId } = await params; const { app } = await requireInfraApp(appId); + const videoCount = await getInfraVideoCount(app.id); return ( ); } diff --git a/apps/web/app/(infra)/dashboard/infra/_components/shell.tsx b/apps/web/app/(infra)/dashboard/infra/_components/shell.tsx index f9cc41a118c..2ca11a5c289 100644 --- a/apps/web/app/(infra)/dashboard/infra/_components/shell.tsx +++ b/apps/web/app/(infra)/dashboard/infra/_components/shell.tsx @@ -71,10 +71,12 @@ const ACCOUNT_SECTIONS = new Set(["billing", "keys", "settings"]); export function Shell({ apps, appId, + balanceMicroCredits, children, }: { apps: InfraAppSummary[]; appId: string; + balanceMicroCredits: number; children: React.ReactNode; }) { const pathname = usePathname(); @@ -218,7 +220,7 @@ export function Shell({ "m-0 mt-1.5 text-[21px] leading-[1.1] tracking-[-0.04em] tabular-nums text-white [overflow-wrap:anywhere]", )} > - {formatCredits(current.balanceMicroCredits)} + {formatCredits(balanceMicroCredits)}

; -export type DeveloperApp = typeof developerApps.$inferSelect & { - domains: (typeof developerAppDomains.$inferSelect)[]; - apiKeys: DeveloperApiKey[]; - creditAccount: typeof developerCreditAccounts.$inferSelect | null; - videoCount: number; -}; +export type InfraApp = Pick< + typeof developerApps.$inferSelect, + "id" | "name" | "environment" | "createdAt" +>; -export type InfraAppSummary = { - id: string; - name: string; - environment: DeveloperApp["environment"]; - balanceMicroCredits: number; -}; +export type InfraCreditAccount = typeof developerCreditAccounts.$inferSelect; -export async function getDeveloperApps( +export type InfraAppSummary = Pick; + +export function getDeveloperApps( user: Pick, -): Promise { - const apps = await db() - .select() +): Promise { + return db() + .select({ + id: developerApps.id, + name: developerApps.name, + environment: developerApps.environment, + createdAt: developerApps.createdAt, + }) .from(developerApps) .where( and(eq(developerApps.ownerId, user.id), isNull(developerApps.deletedAt)), ) .orderBy(desc(developerApps.createdAt)); +} - if (apps.length === 0) return []; - - const appIds = apps.map((a) => a.id); +const getInfraCreditAccount = cache( + async (appId: string): Promise => { + const [account] = await db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + return account ?? null; + }, +); - const [allDomains, allApiKeys, allCreditAccounts, allVideoCounts] = - await Promise.all([ - db() - .select() - .from(developerAppDomains) - .where(inArray(developerAppDomains.appId, appIds)), - db() - .select({ - id: developerApiKeys.id, - appId: developerApiKeys.appId, - keyType: developerApiKeys.keyType, - keyPrefix: developerApiKeys.keyPrefix, - createdAt: developerApiKeys.createdAt, - revokedAt: developerApiKeys.revokedAt, - }) - .from(developerApiKeys) - .where( - and( - inArray(developerApiKeys.appId, appIds), - isNull(developerApiKeys.revokedAt), - ), +export const getInfraApiKeys = cache( + (appId: string): Promise => + db() + .select({ + id: developerApiKeys.id, + keyType: developerApiKeys.keyType, + keyPrefix: developerApiKeys.keyPrefix, + createdAt: developerApiKeys.createdAt, + revokedAt: developerApiKeys.revokedAt, + }) + .from(developerApiKeys) + .where( + and( + eq(developerApiKeys.appId, appId), + isNull(developerApiKeys.revokedAt), ), - db() - .select() - .from(developerCreditAccounts) - .where(inArray(developerCreditAccounts.appId, appIds)), - db() - .select({ - appId: developerVideos.appId, - count: count(), - }) - .from(developerVideos) - .where( - and( - inArray(developerVideos.appId, appIds), - isNull(developerVideos.deletedAt), - ), - ) - .groupBy(developerVideos.appId), - ]); - - const domainsByApp = new Map(); - for (const d of allDomains) { - const list = domainsByApp.get(d.appId) ?? []; - list.push(d); - domainsByApp.set(d.appId, list); - } - - const keysByApp = new Map(); - for (const k of allApiKeys) { - const list = keysByApp.get(k.appId) ?? []; - list.push(k); - keysByApp.set(k.appId, list); - } - - const accountsByApp = new Map(allCreditAccounts.map((c) => [c.appId, c])); - const countsByApp = new Map(allVideoCounts.map((v) => [v.appId, v.count])); + ), +); - return apps.map((app) => ({ - ...app, - domains: domainsByApp.get(app.id) ?? [], - apiKeys: (keysByApp.get(app.id) ?? []).map((k) => ({ - id: k.id, - keyType: k.keyType, - keyPrefix: k.keyPrefix, - createdAt: k.createdAt, - revokedAt: k.revokedAt, - })), - creditAccount: accountsByApp.get(app.id) ?? null, - videoCount: countsByApp.get(app.id) ?? 0, - })); +export async function getInfraVideoCount(appId: string) { + const [row] = await db() + .select({ count: count() }) + .from(developerVideos) + .where( + and(eq(developerVideos.appId, appId), isNull(developerVideos.deletedAt)), + ); + return row?.count ?? 0; } export async function getDeveloperAppVideos( @@ -308,10 +271,14 @@ export async function getSelectedInfraApp() { } export async function requireInfraApp(appId: string) { - const apps = await getInfraApps(infraAppPath(appId)); + const user = await requireInfraUser(infraAppPath(appId)); + const [apps, creditAccount] = await Promise.all([ + getInfraAppsForUser(user.id), + getInfraCreditAccount(appId), + ]); const app = apps.find((a) => a.id === appId); if (!app) notFound(); - return { app, apps }; + return { app: { ...app, creditAccount }, apps }; } // A page title is only sent once the app is known to exist for this viewer. @@ -330,13 +297,8 @@ export const infraPageMetadata = return { title: `${title} | Cap Infra` }; }; -export function toInfraAppSummary(app: DeveloperApp): InfraAppSummary { - return { - id: app.id, - name: app.name, - environment: app.environment, - balanceMicroCredits: app.creditAccount?.balanceMicroCredits ?? 0, - }; +export function toInfraAppSummary(app: InfraApp): InfraAppSummary { + return { id: app.id, name: app.name, environment: app.environment }; } export async function getInfraStorageSnapshots( @@ -355,6 +317,7 @@ export async function getInfraStorageSnapshots( and( eq(developerDailyStorageSnapshots.appId, appId), gte(developerDailyStorageSnapshots.snapshotDate, sinceDateKey), + gt(developerDailyStorageSnapshots.totalDurationMinutes, 0), ), ) .orderBy(desc(developerDailyStorageSnapshots.snapshotDate)); diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts index 10c38da0f33..727fb01d2ed 100644 --- a/apps/web/app/api/cron/developer-storage/route.ts +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -4,102 +4,265 @@ import { developerApps, developerCreditAccounts, developerCreditTransactions, + developerDailyStorageSnapshots, } from "@cap/database/schema"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { NextResponse } from "next/server"; +import { and, asc, eq, gt, inArray, isNotNull, isNull, lt } from "drizzle-orm"; +import { after, NextResponse } from "next/server"; +import type { StorageUsage } from "@/lib/developer-pricing"; import { runDueAutoTopUps } from "@/lib/infra-auto-top-up"; import { sendInfraOutOfCreditEmail } from "@/lib/infra-billing-emails"; import { INFRA_GRACE_DAYS, - latestStorageCharge, + latestStorageCharges, STORAGE_DAY_REFERENCE_TYPE, + type StorageCharge, } from "@/lib/infra-standing"; import { getStorageUsageByApp } from "@/lib/infra-storage"; -import { chargeStorageDay } from "@/lib/infra-storage-billing"; +import { + chargeStorageDay, + recordStorageDayVisits, +} from "@/lib/infra-storage-billing"; import { utcDateKey } from "@/lib/infra-usage"; const BATCH_SIZE = 10; -const GRACE_MS = INFRA_GRACE_DAYS * 24 * 60 * 60 * 1000; +const PAGE_SIZE = 200; +const DAY_MS = 24 * 60 * 60 * 1000; +const GRACE_MS = INFRA_GRACE_DAYS * DAY_MS; -export async function GET(request: Request) { - const cronSecret = process.env.CRON_SECRET; - if (!cronSecret) { - return NextResponse.json( - { error: "Server misconfiguration" }, - { status: 500 }, - ); - } +const TOP_UPS_BEFORE_UNTIL_MS = 60_000; +const STORAGE_UNTIL_MS = 200_000; +const TOP_UPS_AFTER_UNTIL_MS = 240_000; - const authHeader = request.headers.get("authorization"); - const expected = `Bearer ${cronSecret}`; - if ( - !authHeader || - authHeader.length !== expected.length || - !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } +export const MAX_STORAGE_HOPS = 24; +const HANDOFF_TIMEOUT_MS = 10_000; - const now = new Date(); - const today = utcDateKey(now); +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const ACCOUNT_ID_PATTERN = /^[0-9A-Za-z_-]{1,64}$/; +const HOP_PATTERN = /^\d{1,3}$/; - const accounts = await db() - .select({ - id: developerCreditAccounts.id, - appId: developerCreditAccounts.appId, +export const maxDuration = 300; + +type Sweep = { date: string; after: string | null; hop: number }; + +function readSweep(url: URL, today: string, yesterday: string): Sweep | null { + const date = url.searchParams.get("date"); + const afterId = url.searchParams.get("after"); + const hop = url.searchParams.get("hop"); + if (date === null && afterId === null && hop === null) { + return { date: yesterday, after: null, hop: 0 }; + } + if (date === null || hop === null || !HOP_PATTERN.test(hop)) return null; + const hopNumber = Number(hop); + if (hopNumber < 1 || hopNumber > MAX_STORAGE_HOPS) return null; + if (afterId !== null && !ACCOUNT_ID_PATTERN.test(afterId)) return null; + if (!DATE_PATTERN.test(date) || (date !== today && date !== yesterday)) { + return null; + } + return { date, after: afterId, hop: hopNumber }; +} + +function continueSweep(request: Request, authorization: string, next: Sweep) { + const url = new URL(request.url); + url.search = ""; + url.searchParams.set("date", next.date); + if (next.after) url.searchParams.set("after", next.after); + url.searchParams.set("hop", String(next.hop)); + after( + fetch(url, { + headers: { authorization }, + signal: AbortSignal.timeout(HANDOFF_TIMEOUT_MS), }) - .from(developerCreditAccounts) - .innerJoin( - developerApps, - eq(developerCreditAccounts.appId, developerApps.id), - ) - .where(isNull(developerApps.deletedAt)); + .then((response) => { + if (!response.ok) { + console.error("[developer-storage] Continuation refused", { + status: response.status, + ...next, + }); + } + }) + .catch((error: unknown) => { + if (error instanceof Error && error.name === "TimeoutError") { + console.info( + "[developer-storage] Continuation still running at handoff", + next, + ); + return; + } + console.error("[developer-storage] Continuation not started", { + error, + ...next, + }); + }), + ); +} - const topUpsBefore = await runDueAutoTopUps(now); +type DateSweep = { + complete: boolean; + cursor: string | null; + processed: number; + short: number; +}; +async function sweepDate({ + date, + from, + finishing, + now, + outOfTime, +}: { + date: string; + from: string | null; + finishing: boolean; + now: Date; + outOfTime: () => boolean; +}): Promise { let processed = 0; let short = 0; + let cursor = from; + const dayEnd = finishing + ? new Date(Date.parse(`${date}T00:00:00.000Z`) + DAY_MS) + : null; - if (accounts.length > 0) { - const billedToday = await db() - .select({ accountId: developerCreditTransactions.accountId }) - .from(developerCreditTransactions) - .where( - and( - inArray( - developerCreditTransactions.accountId, - accounts.map((account) => account.id), - ), - eq(developerCreditTransactions.referenceId, today), - eq( - developerCreditTransactions.referenceType, - STORAGE_DAY_REFERENCE_TYPE, - ), - ), - ); + while (!outOfTime()) { + const page: { id: string; appId: string; createdAt: Date }[] = await db() + .select({ + id: developerCreditAccounts.id, + appId: developerCreditAccounts.appId, + createdAt: developerCreditAccounts.createdAt, + }) + .from(developerCreditAccounts) + .where(cursor ? gt(developerCreditAccounts.id, cursor) : undefined) + .orderBy(asc(developerCreditAccounts.id)) + .limit(PAGE_SIZE); + const lastAccount = page.at(-1); + if (!lastAccount) return { complete: true, cursor, processed, short }; + const existing = dayEnd + ? page.filter( + (account) => new Date(account.createdAt).getTime() < dayEnd.getTime(), + ) + : page; + const liveApps = + existing.length > 0 + ? await db() + .select({ id: developerApps.id }) + .from(developerApps) + .where( + and( + inArray( + developerApps.id, + existing.map((account) => account.appId), + ), + isNull(developerApps.deletedAt), + dayEnd ? lt(developerApps.createdAt, dayEnd) : undefined, + ), + ) + : []; + const liveAppIds = new Set(liveApps.map((app) => app.id)); + const accounts = existing.filter((account) => + liveAppIds.has(account.appId), + ); + + const billedToday = + accounts.length > 0 + ? await db() + .select({ accountId: developerCreditTransactions.accountId }) + .from(developerCreditTransactions) + .where( + and( + inArray( + developerCreditTransactions.accountId, + accounts.map((account) => account.id), + ), + eq(developerCreditTransactions.referenceId, date), + eq( + developerCreditTransactions.referenceType, + STORAGE_DAY_REFERENCE_TYPE, + ), + ), + ) + : []; const billedAccounts = new Set(billedToday.map((row) => row.accountId)); - const pending = accounts.filter( + const unbilled = accounts.filter( (account) => !billedAccounts.has(account.id), ); - const usageByApp = await getStorageUsageByApp( - pending.map((account) => account.appId), - ); + const settledApps = + unbilled.length > 0 + ? await db() + .select({ appId: developerDailyStorageSnapshots.appId }) + .from(developerDailyStorageSnapshots) + .where( + and( + inArray( + developerDailyStorageSnapshots.appId, + unbilled.map((account) => account.appId), + ), + eq(developerDailyStorageSnapshots.snapshotDate, date), + isNotNull(developerDailyStorageSnapshots.processedAt), + ), + ) + : []; + const settledAppIds = new Set(settledApps.map((row) => row.appId)); + const pending = finishing + ? unbilled.filter((account) => !settledAppIds.has(account.appId)) + : unbilled; + const visitedWithoutSnapshot: string[] = []; + const recordVisits = () => + recordStorageDayVisits({ + appIds: visitedWithoutSnapshot.filter( + (appId) => !settledAppIds.has(appId), + ), + dateKey: date, + now, + }); + const appIds = pending.map((account) => account.appId); + const usageByApp = + pending.length === 0 + ? new Map() + : dayEnd + ? await getStorageUsageByApp(appIds, undefined, undefined, { + createdBefore: dayEnd, + }) + : await getStorageUsageByApp(appIds); + const idleAccountIds = pending + .filter( + (account) => + (usageByApp.get(account.appId)?.dailyMicroCredits ?? 1) <= 0, + ) + .map((account) => account.id); + const lastCharges = + idleAccountIds.length > 0 + ? await latestStorageCharges(db(), idleAccountIds) + : new Map(); for (let i = 0; i < pending.length; i += BATCH_SIZE) { + if (outOfTime()) { + await recordVisits(); + const firstUnattempted = pending[i]; + const position = firstUnattempted ? page.indexOf(firstUnattempted) : -1; + return { + complete: false, + cursor: position > 0 ? (page[position - 1]?.id ?? cursor) : cursor, + processed, + short, + }; + } const results = await Promise.allSettled( pending.slice(i, i + BATCH_SIZE).map(async (account) => { const usage = usageByApp.get(account.appId); if (!usage) return null; - if (usage.dailyMicroCredits <= 0) { - const lastCharge = await latestStorageCharge(db(), account.id); - if (!lastCharge?.shortSince) return null; + if ( + usage.dailyMicroCredits <= 0 && + !lastCharges.get(account.id)?.shortSince + ) { + return null; } const result = await chargeStorageDay({ appId: account.appId, accountId: account.id, usage, - dateKey: today, + dateKey: date, now, + retryUnpaid: !finishing, }); if ( result.state === "billed" && @@ -116,6 +279,16 @@ export async function GET(request: Request) { }), ); + results.forEach((result, index) => { + const appId = pending[i + index]?.appId; + if ( + appId && + result.status === "fulfilled" && + (!result.value || result.value.state === "skipped") + ) { + visitedWithoutSnapshot.push(appId); + } + }); for (const result of results) { if (result.status === "rejected") { console.error("Failed to process app in cron:", result.reason); @@ -127,18 +300,113 @@ export async function GET(request: Request) { } } } + + await recordVisits(); + cursor = lastAccount.id; + if (page.length < PAGE_SIZE) { + return { complete: true, cursor, processed, short }; + } + } + return { complete: false, cursor, processed, short }; +} + +export async function GET(request: Request) { + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + return NextResponse.json( + { error: "Server misconfiguration" }, + { status: 500 }, + ); + } + + const authHeader = request.headers.get("authorization"); + const expected = `Bearer ${cronSecret}`; + if ( + !authHeader || + authHeader.length !== expected.length || + !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const startedAt = Date.now(); + const now = new Date(startedAt); + const today = utcDateKey(now); + const yesterday = utcDateKey(new Date(startedAt - DAY_MS)); + const sweep = readSweep(new URL(request.url), today, yesterday); + if (!sweep) { + return NextResponse.json( + { error: "Invalid continuation" }, + { status: 400 }, + ); + } + const { hop } = sweep; + const outOfTime = () => Date.now() - startedAt >= STORAGE_UNTIL_MS; + + const topUpsBefore = + hop === 0 + ? await runDueAutoTopUps(now, { + until: startedAt + TOP_UPS_BEFORE_UNTIL_MS, + }) + : null; + + const dates = sweep.date === today ? [today] : [sweep.date, today]; + let date = sweep.date; + let cursor = sweep.after; + let complete = false; + let processed = 0; + let short = 0; + for (const [index, sweepingDate] of dates.entries()) { + date = sweepingDate; + const result = await sweepDate({ + date, + from: index === 0 ? sweep.after : null, + finishing: date !== today, + now, + outOfTime, + }); + processed += result.processed; + short += result.short; + cursor = result.cursor; + complete = result.complete; + if (!complete) break; + } + + let continued = false; + if (!complete) { + if (hop < MAX_STORAGE_HOPS) { + continueSweep(request, authHeader, { + date, + after: cursor, + hop: hop + 1, + }); + continued = true; + } else { + console.error("[developer-storage] Sweep stopped at the hop limit", { + date, + after: cursor, + hop, + }); + } } - const topUpsAfter = await runDueAutoTopUps(now); + const topUpsAfter = continued + ? null + : await runDueAutoTopUps(now, { + until: startedAt + TOP_UPS_AFTER_UNTIL_MS, + }); const autoTopUps = { - attempted: topUpsBefore.attempted + topUpsAfter.attempted, - succeeded: topUpsBefore.succeeded + topUpsAfter.succeeded, - failed: topUpsBefore.failed + topUpsAfter.failed, + attempted: (topUpsBefore?.attempted ?? 0) + (topUpsAfter?.attempted ?? 0), + succeeded: (topUpsBefore?.succeeded ?? 0) + (topUpsAfter?.succeeded ?? 0), + failed: (topUpsBefore?.failed ?? 0) + (topUpsAfter?.failed ?? 0), }; return NextResponse.json({ success: true, - date: today, + date, + complete, + continued, + hop, appsProcessed: processed, appsShort: short, autoTopUps, diff --git a/apps/web/app/api/developer/v1/[...route]/usage.ts b/apps/web/app/api/developer/v1/[...route]/usage.ts index 7d9e5001298..284b6ee112e 100644 --- a/apps/web/app/api/developer/v1/[...route]/usage.ts +++ b/apps/web/app/api/developer/v1/[...route]/usage.ts @@ -14,7 +14,6 @@ import { infraBillingJson, infraStorageJson, } from "@/lib/infra-standing"; -import { getAppStorageUsage } from "@/lib/infra-storage"; import { creditDollars } from "@/lib/infra-usage"; import { withDeveloperSecretAuth } from "../../../utils"; @@ -28,7 +27,7 @@ export const app = new Hono<{ app.get("/", async (c) => { const appId = c.get("developerAppId"); - const [[videoStats], billing, stored, credit] = await Promise.all([ + const [[videoStats], billing, credit] = await Promise.all([ db() .select({ createdVideos: count(), @@ -42,9 +41,9 @@ app.get("/", async (c) => { ), ), getInfraBilling(appId), - getAppStorageUsage(appId), getSpeechUsage(appId), ]); + const { stored } = billing; const { balanceMicroCredits, ...speech } = credit; const { standing, upload } = infraBillingJson(billing); diff --git a/apps/web/lib/infra-auto-top-up.ts b/apps/web/lib/infra-auto-top-up.ts index bceba7b7c11..47839d7f95c 100644 --- a/apps/web/lib/infra-auto-top-up.ts +++ b/apps/web/lib/infra-auto-top-up.ts @@ -7,13 +7,22 @@ import { users, } from "@cap/database/schema"; import { stripe } from "@cap/utils"; -import { and, desc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + sql, +} from "drizzle-orm"; import type Stripe from "stripe"; import { addCreditsToAccount } from "@/lib/developer-credits"; import { developerCreditsProductId } from "@/lib/developer-credits-product"; import { MICRO_CREDITS_PER_DOLLAR } from "@/lib/developer-pricing"; import { canUseInfra } from "@/lib/infra-access"; -import { availableBalance } from "@/lib/infra-balance"; +import { availableBalance, heldTranscriptsByApp } from "@/lib/infra-balance"; import { sendInfraTopUpFailedEmail } from "@/lib/infra-billing-emails"; import { disputedSince } from "@/lib/infra-standing"; import { utcDateKey } from "@/lib/infra-usage"; @@ -30,6 +39,7 @@ export const AUTO_TOP_UP_MAX_THRESHOLD_MICRO_CREDITS = 1_000 * MICRO_CREDITS_PER_DOLLAR; const ATTEMPT_INTERVAL_MS = 24 * 60 * 60 * 1000; +const DUE_PAGE_SIZE = 200; export type AutoTopUpResult = | { @@ -132,6 +142,31 @@ const SETTLED_REFERENCE_TYPES = [ AUTO_TOP_UP_FAILED_REFERENCE_TYPE, ]; +const ATTEMPT_LEDGER_TYPES = { + [AUTO_TOP_UP_REFERENCE_TYPE]: "topup", + [AUTO_TOP_UP_FAILED_REFERENCE_TYPE]: "topup_failed", + [AUTO_TOP_UP_PENDING_REFERENCE_TYPE]: "topup_pending", +} as const; + +const NEVER_ATTEMPTED = "1970-01-01 00:00:00"; + +const sqlTime = (date: Date) => + date.toISOString().slice(0, 23).replace("T", " "); + +export function autoTopUpRetryCutoff(now: Date) { + return new Date(now.getTime() - ATTEMPT_INTERVAL_MS); +} + +export function autoTopUpLastAttemptAt() { + return sql`(select max(${developerCreditTransactions.createdAt}) from ${developerCreditTransactions} where ${developerCreditTransactions.accountId} = ${developerCreditAccounts.id} and ${inArray( + developerCreditTransactions.type, + ATTEMPT_REFERENCE_TYPES.map((type) => ATTEMPT_LEDGER_TYPES[type]), + )} and ${inArray( + developerCreditTransactions.referenceType, + ATTEMPT_REFERENCE_TYPES, + )})`; +} + async function lastAttempt( accountId: string, referenceTypes: typeof ATTEMPT_REFERENCE_TYPES = ATTEMPT_REFERENCE_TYPES, @@ -146,6 +181,10 @@ async function lastAttempt( .where( and( eq(developerCreditTransactions.accountId, accountId), + inArray( + developerCreditTransactions.type, + referenceTypes.map((type) => ATTEMPT_LEDGER_TYPES[type]), + ), inArray(developerCreditTransactions.referenceType, referenceTypes), ), ) @@ -365,7 +404,7 @@ export async function runAutoTopUp( const previous = await lastAttempt(accountId); if ( previous && - now.getTime() - new Date(previous.createdAt).getTime() < ATTEMPT_INTERVAL_MS + new Date(previous.createdAt).getTime() > autoTopUpRetryCutoff(now).getTime() ) { return { state: "skipped", reason: "recent_attempt" }; } @@ -443,38 +482,65 @@ export async function runAutoTopUp( return { state: "failed", reason: paymentIntent.status }; } -export async function runDueAutoTopUps(now: Date = new Date()) { - const due = await db() - .select({ id: developerCreditAccounts.id }) +type DueCursor = { attemptKey: string; id: string }; + +function dueAutoTopUpsAfter(now: Date, cursor: DueCursor | null) { + const attemptKey = sql`cast(coalesce(${autoTopUpLastAttemptAt()}, ${NEVER_ATTEMPTED}) as char)`; + const held = heldTranscriptsByApp(db()); + return db() + .select({ + id: developerCreditAccounts.id, + attemptKey: sql`${attemptKey}`.as("attemptKey"), + }) .from(developerCreditAccounts) .innerJoin( developerApps, eq(developerCreditAccounts.appId, developerApps.id), ) + .leftJoin(held, eq(held.appId, developerCreditAccounts.appId)) .where( and( eq(developerCreditAccounts.autoTopUpEnabled, true), isNotNull(developerCreditAccounts.stripePaymentMethodId), isNull(developerApps.deletedAt), + sql`${developerCreditAccounts.balanceMicroCredits} < ${developerCreditAccounts.autoTopUpThresholdMicroCredits} + coalesce(${held.heldMicroCredits}, 0)`, + sql`${attemptKey} <= ${sqlTime(autoTopUpRetryCutoff(now))}`, + cursor + ? sql`(${attemptKey} > ${cursor.attemptKey} or (${attemptKey} = ${cursor.attemptKey} and ${developerCreditAccounts.id} > ${cursor.id}))` + : undefined, ), - ); + ) + .orderBy(sql`attemptKey`, asc(developerCreditAccounts.id)) + .limit(DUE_PAGE_SIZE); +} +export async function runDueAutoTopUps( + now: Date = new Date(), + { until = Number.POSITIVE_INFINITY }: { until?: number } = {}, +) { const summary = { attempted: 0, succeeded: 0, failed: 0 }; - for (const account of due) { - try { - const result = await runAutoTopUp(account.id, now); - if (result.state === "skipped") continue; - summary.attempted += 1; - if (result.state === "succeeded") summary.succeeded += 1; - if (result.state === "failed") summary.failed += 1; - } catch (error) { - console.error("[infra-billing] Auto top-up failed", { - accountId: account.id, - error, - }); + let cursor: DueCursor | null = null; + for (;;) { + const due: DueCursor[] = await dueAutoTopUpsAfter(now, cursor); + for (const account of due) { + if (Date.now() >= until) return summary; + try { + const result = await runAutoTopUp(account.id, now); + if (result.state === "skipped") continue; + summary.attempted += 1; + if (result.state === "succeeded") summary.succeeded += 1; + if (result.state === "failed") summary.failed += 1; + } catch (error) { + console.error("[infra-billing] Auto top-up failed", { + accountId: account.id, + error, + }); + } } + const last = due.at(-1); + if (!last || due.length < DUE_PAGE_SIZE) return summary; + cursor = last; } - return summary; } export async function settleAutoTopUpPaymentIntent( diff --git a/apps/web/lib/infra-balance.ts b/apps/web/lib/infra-balance.ts index f25bbb41e6f..828c7692e1c 100644 --- a/apps/web/lib/infra-balance.ts +++ b/apps/web/lib/infra-balance.ts @@ -91,3 +91,19 @@ export async function availableBalance( (await heldTranscriptMicroCredits(executor, appId)) ); } + +export const heldTranscriptsByApp = ( + executor: Pick, +) => + executor + .select({ + appId: developerTranscripts.appId, + heldMicroCredits: + sql`COALESCE(SUM(${developerTranscripts.reservedMicroCredits}), 0)`.as( + "heldMicroCredits", + ), + }) + .from(developerTranscripts) + .where(eq(developerTranscripts.status, "processing")) + .groupBy(developerTranscripts.appId) + .as("held"); diff --git a/apps/web/lib/infra-ledger-store.ts b/apps/web/lib/infra-ledger-store.ts index 0e97f319d06..7a972f93e9b 100644 --- a/apps/web/lib/infra-ledger-store.ts +++ b/apps/web/lib/infra-ledger-store.ts @@ -3,7 +3,7 @@ import { developerCreditAccounts, developerCreditTransactions, } from "@cap/database/schema"; -import { and, count, desc, eq, lt, or } from "drizzle-orm"; +import { and, asc, desc, eq, gt, lt, or } from "drizzle-orm"; import { type LedgerRow, orderLedgerRows } from "@/lib/infra-ledger"; const EXPORT_BATCH_SIZE = 1_000; @@ -36,6 +36,46 @@ const newestFirst = [ desc(developerCreditTransactions.id), ] as const; +const oldestFirst = [ + asc(developerCreditTransactions.createdAt), + asc(developerCreditTransactions.id), +] as const; + +export type LedgerCursor = { createdAt: Date; id: string }; + +const CURSOR_PATTERN = /^(\d{1,15})\.([0-9a-z]{1,32})$/; + +export function formatLedgerCursor(row: LedgerCursor) { + return `${new Date(row.createdAt).getTime()}.${row.id}`; +} + +export function parseLedgerCursor( + raw: string | null | undefined, +): LedgerCursor | null { + const match = raw ? CURSOR_PATTERN.exec(raw) : null; + if (!match?.[1] || !match[2]) return null; + const createdAt = new Date(Number(match[1])); + return Number.isNaN(createdAt.getTime()) ? null : { createdAt, id: match[2] }; +} + +const olderThan = (cursor: LedgerCursor) => + or( + lt(developerCreditTransactions.createdAt, cursor.createdAt), + and( + eq(developerCreditTransactions.createdAt, cursor.createdAt), + lt(developerCreditTransactions.id, cursor.id), + ), + ); + +const newerThan = (cursor: LedgerCursor) => + or( + gt(developerCreditTransactions.createdAt, cursor.createdAt), + and( + eq(developerCreditTransactions.createdAt, cursor.createdAt), + gt(developerCreditTransactions.id, cursor.id), + ), + ); + export async function listAppLedger( appId: string, { limit, offset }: { limit: number; offset: number }, @@ -51,39 +91,64 @@ export async function listAppLedger( }; } -export async function countAppLedger(appId: string) { - const [row] = await db() - .select({ total: count() }) - .from(developerCreditTransactions) - .innerJoin( - developerCreditAccounts, - eq(developerCreditTransactions.accountId, developerCreditAccounts.id), - ) - .where(ofApp(appId)); - return Number(row?.total ?? 0) || 0; +export type LedgerPage = { + rows: LedgerRow[]; + newer: string | null; + older: string | null; +}; + +export async function listAppLedgerPage( + appId: string, + { + limit, + before = null, + after = null, + }: { + limit: number; + before?: LedgerCursor | null; + after?: LedgerCursor | null; + }, +): Promise { + if (after) { + const fetched: LedgerRow[] = await appLedger() + .where(and(ofApp(appId), newerThan(after))) + .orderBy(...oldestFirst) + .limit(limit + 1); + if (fetched.length >= limit) { + const page = fetched.slice(0, limit).reverse(); + const newest = page[0]; + const oldest = page.at(-1); + return { + rows: orderLedgerRows(page), + newer: + fetched.length > limit && newest ? formatLedgerCursor(newest) : null, + older: oldest ? formatLedgerCursor(oldest) : null, + }; + } + } + + const cursor = after ? null : before; + const fetched: LedgerRow[] = await appLedger() + .where(and(ofApp(appId), cursor ? olderThan(cursor) : undefined)) + .orderBy(...newestFirst) + .limit(limit + 1); + const page = fetched.slice(0, limit); + const oldest = page.at(-1); + return { + rows: orderLedgerRows(page), + newer: cursor ? formatLedgerCursor(page[0] ?? cursor) : null, + older: fetched.length > limit && oldest ? formatLedgerCursor(oldest) : null, + }; } export async function* readAppLedger( appId: string, batchSize: number = EXPORT_BATCH_SIZE, ): AsyncGenerator { - let cursor: { createdAt: Date; id: string } | null = null; + let cursor: LedgerCursor | null = null; for (;;) { const rows: LedgerRow[] = await appLedger() - .where( - and( - ofApp(appId), - cursor - ? or( - lt(developerCreditTransactions.createdAt, cursor.createdAt), - and( - eq(developerCreditTransactions.createdAt, cursor.createdAt), - lt(developerCreditTransactions.id, cursor.id), - ), - ) - : undefined, - ), - ) + .where(and(ofApp(appId), cursor ? olderThan(cursor) : undefined)) .orderBy(...newestFirst) .limit(batchSize); const last = rows.at(-1); diff --git a/apps/web/lib/infra-ledger.ts b/apps/web/lib/infra-ledger.ts index 004d2629fd8..37b03cdf727 100644 --- a/apps/web/lib/infra-ledger.ts +++ b/apps/web/lib/infra-ledger.ts @@ -233,25 +233,6 @@ export function orderLedgerRows(rows: readonly Row[]) { return ordered; } -export function ledgerPageWindow( - rawPage: string | undefined, - total: number, - size: number = LEDGER_PAGE_SIZE, -) { - const pages = Math.max(1, Math.ceil(total / size)); - const requested = - rawPage !== undefined && /^\d+$/.test(rawPage) ? Number(rawPage) : 1; - const page = Math.min(Math.max(1, requested), pages); - const offset = (page - 1) * size; - return { - page, - pages, - offset, - from: total === 0 ? 0 : offset + 1, - to: Math.min(total, offset + size), - }; -} - export const LEDGER_CSV_COLUMNS = [ "id", "time_utc", diff --git a/apps/web/lib/infra-standing.ts b/apps/web/lib/infra-standing.ts index 3b8fb6b985f..77b9ee4150c 100644 --- a/apps/web/lib/infra-standing.ts +++ b/apps/web/lib/infra-standing.ts @@ -3,7 +3,7 @@ import { developerCreditAccounts, developerCreditTransactions, } from "@cap/database/schema"; -import { and, desc, eq, gt, gte, inArray } from "drizzle-orm"; +import { and, desc, eq, gt, gte, inArray, lte, sql } from "drizzle-orm"; import { cache } from "react"; import { type BillableVideo, @@ -12,8 +12,8 @@ import { uploadAdmissionMicroCredits, } from "@/lib/developer-pricing"; import { availableBalance } from "@/lib/infra-balance"; -import { getAppStorageUsage } from "@/lib/infra-storage"; -import { formatCredits, formatNeed } from "@/lib/infra-usage"; +import { type AppStorage, getAppStorage } from "@/lib/infra-storage"; +import { formatCredits, formatNeed, utcDateKey } from "@/lib/infra-usage"; export const INFRA_GRACE_DAYS = 7; export const STORAGE_DAY_REFERENCE_TYPE = "storage_day" as const; @@ -111,6 +111,18 @@ export function resolveStanding({ }; } +const storageDayCeiling = () => + utcDateKey(new Date(Date.now() + 24 * 60 * 60 * 1000)); + +const storageDayRows = () => + and( + eq(developerCreditTransactions.type, "storage_daily"), + eq(developerCreditTransactions.referenceType, STORAGE_DAY_REFERENCE_TYPE), + ); + +const latestStorageDay = (accountId: string, ceiling: string) => + sql`(select ${developerCreditTransactions.accountId}, ${developerCreditTransactions.referenceId} from ${developerCreditTransactions} where ${developerCreditTransactions.accountId} = ${accountId} and ${developerCreditTransactions.referenceType} = ${STORAGE_DAY_REFERENCE_TYPE} and ${developerCreditTransactions.referenceId} <= ${ceiling} order by ${developerCreditTransactions.referenceId} desc limit 1)`; + export async function latestStorageCharge( executor: InfraDbExecutor, accountId: string, @@ -124,14 +136,50 @@ export async function latestStorageCharge( .where( and( eq(developerCreditTransactions.accountId, accountId), - eq(developerCreditTransactions.type, "storage_daily"), + storageDayRows(), + lte(developerCreditTransactions.referenceId, storageDayCeiling()), ), ) - .orderBy(desc(developerCreditTransactions.createdAt)) + .orderBy( + desc(developerCreditTransactions.referenceId), + desc(developerCreditTransactions.id), + ) .limit(1); return row ? parseStorageCharge(row) : null; } +export async function latestStorageCharges( + executor: InfraDbExecutor, + accountIds: readonly string[], +) { + const charges = new Map(); + if (accountIds.length === 0) return charges; + const ceiling = storageDayCeiling(); + const rows = await executor + .select({ + id: developerCreditTransactions.id, + accountId: developerCreditTransactions.accountId, + createdAt: developerCreditTransactions.createdAt, + metadata: developerCreditTransactions.metadata, + }) + .from(developerCreditTransactions) + .where( + and( + storageDayRows(), + sql`(${developerCreditTransactions.accountId}, ${developerCreditTransactions.referenceId}) in (select latest.accountId, latest.referenceId from (${sql.join( + accountIds.map((accountId) => latestStorageDay(accountId, ceiling)), + sql` union all `, + )}) latest)`, + ), + ); + for (const row of [...rows].sort((a, b) => (a.id < b.id ? 1 : -1))) { + if (!charges.has(row.accountId)) { + charges.set(row.accountId, parseStorageCharge(row)); + } + } + return charges; +} + export async function restoredSinceCharge( executor: InfraDbExecutor, accountId: string, @@ -315,18 +363,19 @@ export async function getInfraBilling( newVideo?: BillableVideo | null, now: Date = new Date(), executor?: InfraDbExecutor, - options: { excludeVideoId?: string } = {}, + options: { excludeVideoId?: string; storage?: AppStorage } = {}, ) { - const counted = { ...options, includeProcessing: true }; - const [{ account, lastCharge, standing }, usage] = executor + const [{ account, lastCharge, standing }, storage] = executor ? [ await appStanding(appId, now, executor), - await getAppStorageUsage(appId, newVideo, executor, counted), + options.storage ?? + (await getAppStorage(appId, newVideo, executor, options)), ] : await Promise.all([ appStanding(appId, now), - getAppStorageUsage(appId, newVideo, undefined, counted), + options.storage ?? getAppStorage(appId, newVideo, undefined, options), ]); + const usage = storage.counted; const admission = uploadAdmission({ balanceMicroCredits: account?.balanceMicroCredits ?? 0, standing, @@ -336,6 +385,7 @@ export async function getInfraBilling( return { standing, usage, + stored: storage.stored, admission: account ? admission : { @@ -351,7 +401,7 @@ export async function getInfraUploadAdmission( appId: string, newVideo?: BillableVideo | null, executor?: InfraDbExecutor, - options: { excludeVideoId?: string } = {}, + options: { excludeVideoId?: string; storage?: AppStorage } = {}, ): Promise { return (await getInfraBilling(appId, newVideo, new Date(), executor, options)) .admission; diff --git a/apps/web/lib/infra-storage-billing.ts b/apps/web/lib/infra-storage-billing.ts index 32c4bf1db60..113bea85439 100644 --- a/apps/web/lib/infra-storage-billing.ts +++ b/apps/web/lib/infra-storage-billing.ts @@ -53,18 +53,48 @@ export function settleStorageDay({ }; } +export async function recordStorageDayVisits({ + appIds, + dateKey, + now, +}: { + appIds: readonly string[]; + dateKey: string; + now: Date; +}) { + if (appIds.length === 0) return; + await db() + .insert(developerDailyStorageSnapshots) + .values( + appIds.map((appId) => ({ + id: nanoId(), + appId, + snapshotDate: dateKey, + totalDurationMinutes: 0, + videoCount: 0, + microCreditsCharged: 0, + processedAt: now, + })), + ) + .onDuplicateKeyUpdate({ + set: { appId: sql`${developerDailyStorageSnapshots.appId}` }, + }); +} + export async function chargeStorageDay({ appId, accountId, usage, dateKey, now, + retryUnpaid = true, }: { appId: string; accountId: string; usage: StorageUsage; dateKey: string; now: Date; + retryUnpaid?: boolean; }): Promise { return db().transaction(async (tx) => { const [account] = await tx @@ -106,7 +136,10 @@ export async function chargeStorageDay({ ), ) .limit(1); - if (snapshot?.processedAt && Number(snapshot.microCreditsCharged) > 0) { + if ( + snapshot?.processedAt && + (Number(snapshot.microCreditsCharged) > 0 || !retryUnpaid) + ) { return { state: "skipped" as const }; } diff --git a/apps/web/lib/infra-storage.ts b/apps/web/lib/infra-storage.ts index 21f380974ef..6cd2210c247 100644 --- a/apps/web/lib/infra-storage.ts +++ b/apps/web/lib/infra-storage.ts @@ -1,6 +1,6 @@ import { db } from "@cap/database"; import { developerVideos } from "@cap/database/schema"; -import { and, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm"; +import { and, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm"; import { type BillableVideo, billableVideo, @@ -24,12 +24,14 @@ const APP_ID_CHUNK = 500; export type StorageUsageOptions = { includeProcessing?: boolean; excludeVideoId?: string; + createdBefore?: Date; }; async function storedVideoGroups( appIds: readonly string[], executor: InfraDbExecutor, excludeVideoId?: string, + createdBefore?: Date, ) { const groups: StoredVideoGroup[] = []; for (let start = 0; start < appIds.length; start += APP_ID_CHUNK) { @@ -55,6 +57,9 @@ async function storedVideoGroups( isNull(developerVideos.deletedAt), isNotNull(developerVideos.duration), excludeVideoId ? ne(developerVideos.id, excludeVideoId) : undefined, + createdBefore + ? lt(developerVideos.createdAt, createdBefore) + : undefined, ), ) .groupBy( @@ -85,6 +90,7 @@ export async function getStorageUsageByApp( appIds, executor, options.excludeVideoId, + options.createdBefore, )) { entriesByApp.get(group.appId)?.push({ video: billableVideo({ @@ -123,3 +129,34 @@ export async function getAppStorageUsage( ); return usageByApp.get(appId) ?? storageUsage([]); } + +export type AppStorage = { stored: StorageUsage; counted: StorageUsage }; + +export async function getAppStorage( + appId: string, + newVideo?: BillableVideo | null, + executor: InfraDbExecutor = db(), + options: Pick = {}, +): Promise { + const groups = await storedVideoGroups( + [appId], + executor, + options.excludeVideoId, + ); + const usage = (includeProcessing: boolean) => + storageUsage([ + ...groups.map((group) => ({ + video: billableVideo({ + duration: Number(group.seconds), + width: group.width, + height: group.height, + status: group.status, + maxResolutionTier: group.maxResolutionTier, + includeProcessing, + }), + count: Number(group.count) || 0, + })), + ...(newVideo ? [{ video: newVideo, count: 1 }] : []), + ]); + return { stored: usage(false), counted: usage(true) }; +} diff --git a/packages/database/migrations/0056_developer_billing_read_indexes.sql b/packages/database/migrations/0056_developer_billing_read_indexes.sql new file mode 100644 index 00000000000..4e35439f151 --- /dev/null +++ b/packages/database/migrations/0056_developer_billing_read_indexes.sql @@ -0,0 +1,4 @@ +CREATE INDEX `auto_top_up_idx` ON `developer_credit_accounts` (`autoTopUpEnabled`);--> statement-breakpoint +CREATE INDEX `reference_type_id_idx` ON `developer_credit_transactions` (`referenceType`,`referenceId`);--> statement-breakpoint +CREATE INDEX `dev_transcripts_app_completed_idx` ON `developer_transcripts` (`appId`,`status`,`completedAt`,`durationSeconds`,`billedMicroCredits`);--> statement-breakpoint +CREATE INDEX `dev_transcripts_app_status_held_idx` ON `developer_transcripts` (`appId`,`status`,`createdAt`,`deletedAt`,`reservedMicroCredits`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0056_snapshot.json b/packages/database/migrations/meta/0056_snapshot.json new file mode 100644 index 00000000000..4c06422ec84 --- /dev/null +++ b/packages/database/migrations/meta/0056_snapshot.json @@ -0,0 +1,5866 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "7568082e-2748-491f-897f-16005b895884", + "prevId": "2773f4a4-e037-4a2a-9537-3e419186570c", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_in": { + "name": "refresh_token_expires_in", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "tempColumn": { + "name": "tempColumn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "provider_account_id_idx": { + "name": "provider_account_id_idx", + "columns": ["providerAccountId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accounts_id": { + "name": "accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_authorization_codes": { + "name": "agent_api_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_authorization_codes_id": { + "name": "agent_api_authorization_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_idempotency": { + "name": "agent_api_idempotency", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestHash": { + "name": "requestHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "statusCode": { + "name": "statusCode", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response": { + "name": "response", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_operation_key_idx": { + "name": "user_operation_key_idx", + "columns": ["userId", "operation", "keyHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_idempotency_id": { + "name": "agent_api_idempotency_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_keys": { + "name": "agent_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Cap CLI'" + }, + "scopes": { + "name": "scopes", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "token_hash_idx": { + "name": "token_hash_idx", + "columns": ["tokenHash"], + "isUnique": true + }, + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_keys_id": { + "name": "agent_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "agent_api_operations": { + "name": "agent_api_operations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resourceId": { + "name": "resourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resultResourceId": { + "name": "resultResourceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_created_at_idx": { + "name": "user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "state_updated_at_idx": { + "name": "state_updated_at_idx", + "columns": ["state", "updatedAt"], + "isUnique": false + }, + "resource_id_idx": { + "name": "resource_id_idx", + "columns": ["resourceId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "agent_api_operations_id": { + "name": "agent_api_operations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth_api_keys": { + "name": "auth_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_id_created_at_idx": { + "name": "user_id_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_api_keys_id": { + "name": "auth_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorId": { + "name": "authorId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "parentCommentId": { + "name": "parentCommentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaKey": { + "name": "mediaKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaDuration": { + "name": "mediaDuration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mediaMeta": { + "name": "mediaMeta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_type_created_idx": { + "name": "video_type_created_idx", + "columns": ["videoId", "type", "createdAt", "id"], + "isUnique": false + }, + "author_id_idx": { + "name": "author_id_idx", + "columns": ["authorId"], + "isUnique": false + }, + "parent_comment_id_idx": { + "name": "parent_comment_id_idx", + "columns": ["parentCommentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "comments_id": { + "name": "comments_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_api_keys": { + "name": "developer_api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyType": { + "name": "keyType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyPrefix": { + "name": "keyPrefix", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedKey": { + "name": "encryptedKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "key_hash_idx": { + "name": "key_hash_idx", + "columns": ["keyHash"], + "isUnique": true + }, + "app_key_type_idx": { + "name": "app_key_type_idx", + "columns": ["appId", "keyType"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_api_keys_appId_developer_apps_id_fk": { + "name": "developer_api_keys_appId_developer_apps_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_keys_id": { + "name": "developer_api_keys_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_app_domains": { + "name": "developer_app_domains", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "varchar(253)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_app_domains_appId_developer_apps_id_fk": { + "name": "developer_app_domains_appId_developer_apps_id_fk", + "tableFrom": "developer_app_domains", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_app_domains_id": { + "name": "developer_app_domains_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_domain_unique": { + "name": "app_domain_unique", + "columns": ["appId", "domain"] + } + }, + "checkConstraint": {} + }, + "developer_apps": { + "name": "developer_apps", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_deleted_idx": { + "name": "owner_deleted_idx", + "columns": ["ownerId", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "developer_apps_id": { + "name": "developer_apps_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_accounts": { + "name": "developer_credit_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceMicroCredits": { + "name": "balanceMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePaymentMethodId": { + "name": "stripePaymentMethodId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoTopUpEnabled": { + "name": "autoTopUpEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "autoTopUpThresholdMicroCredits": { + "name": "autoTopUpThresholdMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoTopUpAmountCents": { + "name": "autoTopUpAmountCents", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_id_unique": { + "name": "app_id_unique", + "columns": ["appId"], + "isUnique": true + }, + "owner_idx": { + "name": "owner_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "auto_top_up_idx": { + "name": "auto_top_up_idx", + "columns": ["autoTopUpEnabled"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_credit_accounts_appId_developer_apps_id_fk": { + "name": "developer_credit_accounts_appId_developer_apps_id_fk", + "tableFrom": "developer_credit_accounts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_accounts_id": { + "name": "developer_credit_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_credit_transactions": { + "name": "developer_credit_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accountId": { + "name": "accountId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amountMicroCredits": { + "name": "amountMicroCredits", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balanceAfterMicroCredits": { + "name": "balanceAfterMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referenceType": { + "name": "referenceType", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "account_type_created_idx": { + "name": "account_type_created_idx", + "columns": ["accountId", "type", "createdAt"], + "isUnique": false + }, + "account_created_idx": { + "name": "account_created_idx", + "columns": ["accountId", "createdAt"], + "isUnique": false + }, + "account_ref_dedup_idx": { + "name": "account_ref_dedup_idx", + "columns": ["accountId", "referenceId", "referenceType"], + "isUnique": false + }, + "reference_type_id_idx": { + "name": "reference_type_id_idx", + "columns": ["referenceType", "referenceId"], + "isUnique": false + } + }, + "foreignKeys": { + "dev_credit_txn_account_fk": { + "name": "dev_credit_txn_account_fk", + "tableFrom": "developer_credit_transactions", + "tableTo": "developer_credit_accounts", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_credit_transactions_id": { + "name": "developer_credit_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_daily_storage_snapshots": { + "name": "developer_daily_storage_snapshots", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshotDate": { + "name": "snapshotDate", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalDurationMinutes": { + "name": "totalDurationMinutes", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "videoCount": { + "name": "videoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "microCreditsCharged": { + "name": "microCreditsCharged", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "developer_daily_storage_snapshots_appId_developer_apps_id_fk": { + "name": "developer_daily_storage_snapshots_appId_developer_apps_id_fk", + "tableFrom": "developer_daily_storage_snapshots", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_daily_storage_snapshots_id": { + "name": "developer_daily_storage_snapshots_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "app_date_unique": { + "name": "app_date_unique", + "columns": ["appId", "snapshotDate"] + } + }, + "checkConstraint": {} + }, + "developer_transcripts": { + "name": "developer_transcripts", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resultKey": { + "name": "resultKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceBytes": { + "name": "sourceBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "durationSeconds": { + "name": "durationSeconds", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedLanguage": { + "name": "requestedLanguage", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "mediumtext", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wordCount": { + "name": "wordCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservedMicroCredits": { + "name": "reservedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billedMicroCredits": { + "name": "billedMicroCredits", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errorCode": { + "name": "errorCode", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runId": { + "name": "runId", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queuedAt": { + "name": "queuedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "dev_transcripts_app_list_idx": { + "name": "dev_transcripts_app_list_idx", + "columns": ["appId", "deletedAt", "createdAt", "id"], + "isUnique": false + }, + "dev_transcripts_app_user_idx": { + "name": "dev_transcripts_app_user_idx", + "columns": ["appId", "externalUserId", "createdAt"], + "isUnique": false + }, + "dev_transcripts_app_status_idx": { + "name": "dev_transcripts_app_status_idx", + "columns": ["appId", "status", "createdAt"], + "isUnique": false + }, + "dev_transcripts_status_updated_idx": { + "name": "dev_transcripts_status_updated_idx", + "columns": ["status", "updatedAt"], + "isUnique": false + }, + "dev_transcripts_app_completed_idx": { + "name": "dev_transcripts_app_completed_idx", + "columns": [ + "appId", + "status", + "completedAt", + "durationSeconds", + "billedMicroCredits" + ], + "isUnique": false + }, + "dev_transcripts_app_status_held_idx": { + "name": "dev_transcripts_app_status_held_idx", + "columns": [ + "appId", + "status", + "createdAt", + "deletedAt", + "reservedMicroCredits" + ], + "isUnique": false + } + }, + "foreignKeys": { + "developer_transcripts_appId_developer_apps_id_fk": { + "name": "developer_transcripts_appId_developer_apps_id_fk", + "tableFrom": "developer_transcripts", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_transcripts_id": { + "name": "developer_transcripts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "developer_videos": { + "name": "developer_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appId": { + "name": "appId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalUserId": { + "name": "externalUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Untitled'" + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s3Key": { + "name": "s3Key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "playbackId": { + "name": "playbackId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxResolutionTier": { + "name": "maxResolutionTier", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1080p'" + }, + "renditions": { + "name": "renditions", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storedBytes": { + "name": "storedBytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorReason": { + "name": "errorReason", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing": { + "name": "processing", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesDeletedAt": { + "name": "filesDeletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "app_created_idx": { + "name": "app_created_idx", + "columns": ["appId", "createdAt"], + "isUnique": false + }, + "app_user_idx": { + "name": "app_user_idx", + "columns": ["appId", "externalUserId"], + "isUnique": false + }, + "app_deleted_idx": { + "name": "app_deleted_idx", + "columns": ["appId", "deletedAt"], + "isUnique": false + }, + "playback_id_idx": { + "name": "playback_id_idx", + "columns": ["playbackId"], + "isUnique": true + }, + "files_pending_idx": { + "name": "files_pending_idx", + "columns": ["filesDeletedAt", "deletedAt"], + "isUnique": false + } + }, + "foreignKeys": { + "developer_videos_appId_developer_apps_id_fk": { + "name": "developer_videos_appId_developer_apps_id_fk", + "tableFrom": "developer_videos", + "tableTo": "developer_apps", + "columnsFrom": ["appId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_videos_id": { + "name": "developer_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "directory_users": { + "name": "directory_users", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "directoryId": { + "name": "directoryId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "directory_users_organization_id_idx": { + "name": "directory_users_organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "directory_users_user_org_idx": { + "name": "directory_users_user_org_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "directory_users_id": { + "name": "directory_users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "parent_id_idx": { + "name": "parent_id_idx", + "columns": ["parentId"], + "isUnique": false + }, + "space_id_idx": { + "name": "space_id_idx", + "columns": ["spaceId"], + "isUnique": false + }, + "public_parent_id_idx": { + "name": "public_parent_id_idx", + "columns": ["public", "parentId"], + "isUnique": false + }, + "public_space_parent_id_idx": { + "name": "public_space_parent_id_idx", + "columns": ["public", "spaceId", "parentId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "folders_id": { + "name": "folders_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "imported_videos": { + "name": "imported_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_idx": { + "name": "id_idx", + "columns": ["id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "imported_videos_orgId_source_source_id_pk": { + "name": "imported_videos_orgId_source_source_id_pk", + "columns": ["orgId", "source", "source_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "integration_installations": { + "name": "integration_installations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "externalId": { + "name": "externalId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installedByUserId": { + "name": "installedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encryptedCredentials": { + "name": "encryptedCredentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "provider_external_id_idx": { + "name": "provider_external_id_idx", + "columns": ["provider", "externalId"], + "isUnique": true + }, + "organization_provider_display_name_idx": { + "name": "organization_provider_display_name_idx", + "columns": ["organizationId", "provider", "displayName"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "integration_installations_id": { + "name": "integration_installations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_imports": { + "name": "loom_migration_imports", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestId": { + "name": "requestId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_imports_request_idx": { + "name": "loom_migration_imports_request_idx", + "columns": ["requestId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_imports_videoId": { + "name": "loom_migration_imports_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loom_migration_requests": { + "name": "loom_migration_requests", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requestedByUserId": { + "name": "requestedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspaceName": { + "name": "workspaceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerNote": { + "name": "customerNote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customerReply": { + "name": "customerReply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "capMessage": { + "name": "capMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expectedVideoCount": { + "name": "expectedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedVideoCount": { + "name": "importedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queuedVideoCount": { + "name": "queuedVideoCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportCount": { + "name": "activeImportCount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "activeImportLeaseToken": { + "name": "activeImportLeaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "activeImportLeaseUntil": { + "name": "activeImportLeaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorUserId": { + "name": "lastOperatorUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastOperatorAt": { + "name": "lastOperatorAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completedAt": { + "name": "completedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "loom_migration_active_org_idx": { + "name": "loom_migration_active_org_idx", + "columns": ["activeOrganizationId"], + "isUnique": true + }, + "loom_migration_org_created_idx": { + "name": "loom_migration_org_created_idx", + "columns": ["organizationId", "createdAt"], + "isUnique": false + }, + "loom_migration_status_created_idx": { + "name": "loom_migration_status_created_idx", + "columns": ["status", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loom_migration_requests_id": { + "name": "loom_migration_requests_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "loops_sync_jobs": { + "name": "loops_sync_jobs", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "nextAttemptAt": { + "name": "nextAttemptAt", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "leaseToken": { + "name": "leaseToken", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseUntil": { + "name": "leaseUntil", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failures": { + "name": "failures", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lastError": { + "name": "lastError", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profileHash": { + "name": "profileHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teammateJoinedAt": { + "name": "teammateJoinedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedEmail": { + "name": "syncedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSyncedAt": { + "name": "lastSyncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "loops_sync_due_idx": { + "name": "loops_sync_due_idx", + "columns": ["nextAttemptAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "loops_sync_jobs_userId": { + "name": "loops_sync_jobs_userId", + "columns": ["userId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientName": { + "name": "clientName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activatedAt": { + "name": "activatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "client_id_idx": { + "name": "client_id_idx", + "columns": ["clientId"], + "isUnique": true + }, + "inactive_created_at_idx": { + "name": "inactive_created_at_idx", + "columns": ["activatedAt", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_clients_id": { + "name": "mcp_oauth_clients_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_codes": { + "name": "mcp_oauth_codes", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeHash": { + "name": "codeHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirectUri": { + "name": "redirectUri", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "code_hash_idx": { + "name": "code_hash_idx", + "columns": ["codeHash"], + "isUnique": true + }, + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_codes_id": { + "name": "mcp_oauth_codes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_registration_quotas": { + "name": "mcp_oauth_registration_quotas", + "columns": { + "windowId": { + "name": "windowId", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registrations": { + "name": "registrations", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "expires_at_idx": { + "name": "expires_at_idx", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_registration_quotas_windowId": { + "name": "mcp_oauth_registration_quotas_windowId", + "columns": ["windowId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mcp_oauth_tokens": { + "name": "mcp_oauth_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessHash": { + "name": "accessHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshHash": { + "name": "refreshHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessExpiresAt": { + "name": "accessExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refreshExpiresAt": { + "name": "refreshExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "access_hash_idx": { + "name": "access_hash_idx", + "columns": ["accessHash"], + "isUnique": true + }, + "refresh_hash_idx": { + "name": "refresh_hash_idx", + "columns": ["refreshHash"], + "isUnique": true + }, + "family_id_idx": { + "name": "family_id_idx", + "columns": ["familyId"], + "isUnique": false + }, + "client_active_idx": { + "name": "client_active_idx", + "columns": ["clientId", "revokedAt", "refreshExpiresAt"], + "isUnique": false + }, + "refresh_expires_at_idx": { + "name": "refresh_expires_at_idx", + "columns": ["refreshExpiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_tokens_id": { + "name": "mcp_oauth_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "media_processing_budgets": { + "name": "media_processing_budgets", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reserved_bytes": { + "name": "reserved_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "limit_bytes": { + "name": "limit_bytes", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_budget_expiry_idx": { + "name": "media_budget_expiry_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "media_processing_budgets_id": { + "name": "media_processing_budgets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_conversations": { + "name": "messenger_conversations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverByUserId": { + "name": "takeoverByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "takeoverAt": { + "name": "takeoverAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_last_message_idx": { + "name": "user_last_message_idx", + "columns": ["userId", "lastMessageAt"], + "isUnique": false + }, + "anonymous_last_message_idx": { + "name": "anonymous_last_message_idx", + "columns": ["anonymousId", "lastMessageAt"], + "isUnique": false + }, + "mode_last_message_idx": { + "name": "mode_last_message_idx", + "columns": ["mode", "lastMessageAt"], + "isUnique": false + }, + "updated_at_idx": { + "name": "updated_at_idx", + "columns": ["updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "messenger_conversations_id": { + "name": "messenger_conversations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_messages": { + "name": "messenger_messages", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anonymousId": { + "name": "anonymousId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "conversation_created_at_idx": { + "name": "conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + }, + "role_created_at_idx": { + "name": "role_created_at_idx", + "columns": ["role", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "messenger_messages_conversationId_messenger_conversations_id_fk": { + "name": "messenger_messages_conversationId_messenger_conversations_id_fk", + "tableFrom": "messenger_messages", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_messages_id": { + "name": "messenger_messages_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "messenger_support_emails": { + "name": "messenger_support_emails", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversationId": { + "name": "conversationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userEmail": { + "name": "userEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "support_email_user_created_at_idx": { + "name": "support_email_user_created_at_idx", + "columns": ["userId", "createdAt"], + "isUnique": false + }, + "support_email_conversation_created_at_idx": { + "name": "support_email_conversation_created_at_idx", + "columns": ["conversationId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "support_email_conversation_fk": { + "name": "support_email_conversation_fk", + "tableFrom": "messenger_support_emails", + "tableTo": "messenger_conversations", + "columnsFrom": ["conversationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messenger_support_emails_id": { + "name": "messenger_support_emails_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_claims": { + "name": "mobile_push_claims", + "columns": { + "id": { + "name": "id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_claims_id": { + "name": "mobile_push_claims_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "mobile_push_tokens": { + "name": "mobile_push_tokens", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionHash": { + "name": "sessionHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": ["token"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + }, + "session_hash_idx": { + "name": "session_hash_idx", + "columns": ["sessionHash"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mobile_push_tokens_id": { + "name": "mobile_push_tokens_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipientId": { + "name": "recipientId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "org_id_idx": { + "name": "org_id_idx", + "columns": ["orgId"], + "isUnique": false + }, + "type_idx": { + "name": "type_idx", + "columns": ["type"], + "isUnique": false + }, + "read_at_idx": { + "name": "read_at_idx", + "columns": ["readAt"], + "isUnique": false + }, + "created_at_idx": { + "name": "created_at_idx", + "columns": ["createdAt"], + "isUnique": false + }, + "recipient_read_idx": { + "name": "recipient_read_idx", + "columns": ["recipientId", "readAt"], + "isUnique": false + }, + "recipient_created_idx": { + "name": "recipient_created_idx", + "columns": ["recipientId", "createdAt"], + "isUnique": false + }, + "dedup_key_idx": { + "name": "dedup_key_idx", + "columns": ["dedupKey"], + "isUnique": true + }, + "type_recipient_created_idx": { + "name": "type_recipient_created_idx", + "columns": ["type", "recipientId", "createdAt"], + "isUnique": false + }, + "type_recipient_video_created_idx": { + "name": "type_recipient_video_created_idx", + "columns": ["type", "recipientId", "videoId", "createdAt"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notifications_id": { + "name": "notifications_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_directory_sync": { + "name": "organization_directory_sync", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "syncedAt": { + "name": "syncedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "leaseExpiresAt": { + "name": "leaseExpiresAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastError": { + "name": "lastError", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heldRemovals": { + "name": "heldRemovals", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removalsApprovedAt": { + "name": "removalsApprovedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_directory_sync_organizationId": { + "name": "organization_directory_sync_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_invites": { + "name": "organization_invites", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedEmail": { + "name": "invitedEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "invited_email_idx": { + "name": "invited_email_idx", + "columns": ["invitedEmail"], + "isUnique": false + }, + "invited_by_user_id_idx": { + "name": "invited_by_user_id_idx", + "columns": ["invitedByUserId"], + "isUnique": false + }, + "status_idx": { + "name": "status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_invites_id": { + "name": "organization_invites_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hasProSeat": { + "name": "hasProSeat", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "user_id_organization_id_idx": { + "name": "user_id_organization_id_idx", + "columns": ["userId", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_members_id": { + "name": "organization_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_sso": { + "name": "organization_sso", + "columns": { + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchasedByUserId": { + "name": "purchasedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "paidThrough": { + "name": "paidThrough", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "checkoutAttemptId": { + "name": "checkoutAttemptId", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutCurrency": { + "name": "checkoutCurrency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutPriceId": { + "name": "checkoutPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutSessionId": { + "name": "checkoutSessionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkoutStartedAt": { + "name": "checkoutStartedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "sso_stripe_subscription_id_idx": { + "name": "sso_stripe_subscription_id_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_sso_organizationId": { + "name": "organization_sso_organizationId", + "columns": ["organizationId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tombstoneAt": { + "name": "tombstoneAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowedEmailDomain": { + "name": "allowedEmailDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultVideoVisibility": { + "name": "defaultVideoVisibility", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customDomain": { + "name": "customDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domainVerified": { + "name": "domainVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shareableLinkIconUrl": { + "name": "shareableLinkIconUrl", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "workosOrganizationId": { + "name": "workosOrganizationId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workosConnectionId": { + "name": "workosConnectionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "owner_id_tombstone_idx": { + "name": "owner_id_tombstone_idx", + "columns": ["ownerId", "tombstoneAt"], + "isUnique": false + }, + "custom_domain_idx": { + "name": "custom_domain_idx", + "columns": ["customDomain"], + "isUnique": false + }, + "workos_organization_id_idx": { + "name": "workos_organization_id_idx", + "columns": ["workosOrganizationId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organizations_id": { + "name": "organizations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "s3_buckets": { + "name": "s3_buckets", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bucketName": { + "name": "bucketName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessKeyId": { + "name": "accessKeyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretAccessKey": { + "name": "secretAccessKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('aws')" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_organization_idx": { + "name": "owner_organization_idx", + "columns": ["ownerId", "organizationId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "updatedAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "s3_buckets_id": { + "name": "s3_buckets_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sessionToken": { + "name": "sessionToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "session_token_idx": { + "name": "session_token_idx", + "columns": ["sessionToken"], + "isUnique": true + }, + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_videos": { + "name": "shared_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedByUserId": { + "name": "sharedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "shared_by_user_id_idx": { + "name": "shared_by_user_id_idx", + "columns": ["sharedByUserId"], + "isUnique": false + }, + "video_id_organization_id_idx": { + "name": "video_id_organization_id_idx", + "columns": ["videoId", "organizationId"], + "isUnique": false + }, + "video_id_folder_id_idx": { + "name": "video_id_folder_id_idx", + "columns": ["videoId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "shared_videos_id": { + "name": "shared_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "signed_baas": { + "name": "signed_baas", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entityName": { + "name": "entityName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityType": { + "name": "entityType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entityAddress": { + "name": "entityAddress", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerName": { + "name": "signerName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signerTitle": { + "name": "signerTitle", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "noticesEmail": { + "name": "noticesEmail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signatureData": { + "name": "signatureData", + "type": "longtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signedAt": { + "name": "signedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailSentAt": { + "name": "emailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "signed_baa_organization_id_idx": { + "name": "signed_baa_organization_id_idx", + "columns": ["organizationId"], + "isUnique": true + }, + "signed_baa_stripe_subscription_idx": { + "name": "signed_baa_stripe_subscription_idx", + "columns": ["stripeSubscriptionId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "signed_baas_id": { + "name": "signed_baas_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "space_members": { + "name": "space_members", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "user_id_idx": { + "name": "user_id_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_members_id": { + "name": "space_members_id", + "columns": ["id"] + } + }, + "uniqueConstraints": { + "space_id_user_id_unique": { + "name": "space_id_user_id_unique", + "columns": ["spaceId", "userId"] + } + }, + "checkConstraint": {} + }, + "space_videos": { + "name": "space_videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "spaceId": { + "name": "spaceId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedById": { + "name": "addedById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "added_by_id_idx": { + "name": "added_by_id_idx", + "columns": ["addedById"], + "isUnique": false + }, + "space_id_video_id_idx": { + "name": "space_id_video_id_idx", + "columns": ["spaceId", "videoId"], + "isUnique": false + }, + "space_id_folder_id_idx": { + "name": "space_id_folder_id_idx", + "columns": ["spaceId", "folderId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "space_videos_id": { + "name": "space_videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "spaces": { + "name": "spaces", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "primary": { + "name": "primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iconUrl": { + "name": "iconUrl", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "privacy": { + "name": "privacy", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Private'" + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "organization_id_idx": { + "name": "organization_id_idx", + "columns": ["organizationId"], + "isUnique": false + }, + "created_by_id_idx": { + "name": "created_by_id_idx", + "columns": ["createdById"], + "isUnique": false + }, + "public_organization_id_idx": { + "name": "public_organization_id_idx", + "columns": ["public", "organizationId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "spaces_id": { + "name": "spaces_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_integrations": { + "name": "storage_integrations", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organizationId": { + "name": "organizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "encryptedConfig": { + "name": "encryptedConfig", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "googleDriveAccessToken": { + "name": "googleDriveAccessToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveAccessTokenExpiresAt": { + "name": "googleDriveAccessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseId": { + "name": "googleDriveTokenRefreshLeaseId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveTokenRefreshLeaseExpiresAt": { + "name": "googleDriveTokenRefreshLeaseExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "googleDriveStorageQuotaCache": { + "name": "googleDriveStorageQuotaCache", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "owner_provider_idx": { + "name": "owner_provider_idx", + "columns": ["ownerId", "provider"], + "isUnique": false + }, + "owner_active_idx": { + "name": "owner_active_idx", + "columns": ["ownerId", "active"], + "isUnique": false + }, + "organization_provider_idx": { + "name": "organization_provider_idx", + "columns": ["organizationId", "provider"], + "isUnique": false + }, + "organization_active_idx": { + "name": "organization_active_idx", + "columns": ["organizationId", "active", "status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "storage_integrations_id": { + "name": "storage_integrations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "storage_objects": { + "name": "storage_objects", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrationId": { + "name": "integrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objectKey": { + "name": "objectKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "objectKeyHash": { + "name": "objectKeyHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerObjectId": { + "name": "providerObjectId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploadSessionUrl": { + "name": "uploadSessionUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploadStatus": { + "name": "uploadStatus", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "contentType": { + "name": "contentType", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contentLength": { + "name": "contentLength", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "integration_key_hash_idx": { + "name": "integration_key_hash_idx", + "columns": ["integrationId", "objectKeyHash"], + "isUnique": true + }, + "integration_status_idx": { + "name": "integration_status_idx", + "columns": ["integrationId", "uploadStatus"], + "isUnique": false + }, + "integration_object_key_prefix_idx": { + "name": "integration_object_key_prefix_idx", + "columns": ["integrationId", "`objectKey`(191)"], + "isUnique": false + }, + "video_id_idx": { + "name": "video_id_idx", + "columns": ["videoId"], + "isUnique": false + }, + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": { + "storage_objects_integrationId_storage_integrations_id_fk": { + "name": "storage_objects_integrationId_storage_integrations_id_fk", + "tableFrom": "storage_objects", + "tableTo": "storage_integrations", + "columnsFrom": ["integrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "storage_objects_id": { + "name": "storage_objects_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastName": { + "name": "lastName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thirdPartyStripeSubscriptionId": { + "name": "thirdPartyStripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionStatus": { + "name": "stripeSubscriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripeSubscriptionPriceId": { + "name": "stripeSubscriptionPriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferences": { + "name": "preferences", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('null')" + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "onboardingSteps": { + "name": "onboardingSteps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customBucket": { + "name": "customBucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviteQuota": { + "name": "inviteQuota", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "defaultOrgId": { + "name": "defaultOrgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authSessionVersion": { + "name": "authSessionVersion", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "marketingOrigin": { + "name": "marketingOrigin", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + } + }, + "indexes": { + "email_idx": { + "name": "email_idx", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier": { + "name": "verification_tokens_identifier", + "columns": ["identifier"] + } + }, + "uniqueConstraints": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": ["token"] + } + }, + "checkConstraint": {} + }, + "video_edits": { + "name": "video_edits", + "columns": { + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceKey": { + "name": "sourceKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editSpec": { + "name": "editSpec", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "video_edits_videoId_videos_id_fk": { + "name": "video_edits_videoId_videos_id_fk", + "tableFrom": "video_edits", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_edits_videoId": { + "name": "video_edits_videoId", + "columns": ["videoId"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_processing_jobs": { + "name": "video_processing_jobs", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'committing'" + }, + "attempt_id": { + "name": "attempt_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_job_id": { + "name": "remote_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification": { + "name": "verification", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output": { + "name": "output", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "processing_state_retry_video_idx": { + "name": "processing_state_retry_video_idx", + "columns": ["state", "next_retry_at", "video_id"], + "isUnique": false + }, + "processing_state_lease_video_idx": { + "name": "processing_state_lease_video_idx", + "columns": ["state", "lease_expires_at", "video_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_processing_jobs_video_id": { + "name": "video_processing_jobs_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_uploads": { + "name": "video_uploads", + "columns": { + "video_id": { + "name": "video_id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded": { + "name": "uploaded", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "mode": { + "name": "mode", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'uploading'" + }, + "processing_progress": { + "name": "processing_progress", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processing_message": { + "name": "processing_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_file_key": { + "name": "raw_file_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "phase_updated_at_video_id_idx": { + "name": "phase_updated_at_video_id_idx", + "columns": ["phase", "updated_at", "video_id"], + "isUnique": false + }, + "started_at_idx": { + "name": "started_at_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "video_uploads_video_id": { + "name": "video_uploads_video_id", + "columns": ["video_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "video_viewer_grants": { + "name": "video_viewer_grants", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "videoId": { + "name": "videoId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invitedByUserId": { + "name": "invitedByUserId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "video_email_idx": { + "name": "video_email_idx", + "columns": ["videoId", "email"], + "isUnique": true + } + }, + "foreignKeys": { + "video_viewer_grants_videoId_videos_id_fk": { + "name": "video_viewer_grants_videoId_videos_id_fk", + "tableFrom": "video_viewer_grants", + "tableTo": "videos", + "columnsFrom": ["videoId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "video_viewer_grants_id": { + "name": "video_viewer_grants_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "orgId": { + "name": "orgId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'My Video'" + }, + "bucket": { + "name": "bucket", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storageIntegrationId": { + "name": "storageIntegrationId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fps": { + "name": "fps", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transcriptionStatus": { + "name": "transcriptionStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('{\"type\":\"MediaConvert\"}')" + }, + "folderId": { + "name": "folderId", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "effectiveCreatedAt": { + "name": "effectiveCreatedAt", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "generated": { + "as": "COALESCE(\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%s.%fZ'),\n STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.customCreatedAt')), '%Y-%m-%dT%H:%i:%sZ'),\n `createdAt`\n )", + "type": "stored" + } + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "xStreamInfo": { + "name": "xStreamInfo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstViewEmailSentAt": { + "name": "firstViewEmailSentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isScreenshot": { + "name": "isScreenshot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "awsRegion": { + "name": "awsRegion", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "awsBucket": { + "name": "awsBucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "videoStartTime": { + "name": "videoStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audioStartTime": { + "name": "audioStartTime", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobId": { + "name": "jobId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jobStatus": { + "name": "jobStatus", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skipProcessing": { + "name": "skipProcessing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "owner_id_idx": { + "name": "owner_id_idx", + "columns": ["ownerId"], + "isUnique": false + }, + "owner_updated_id_idx": { + "name": "owner_updated_id_idx", + "columns": ["ownerId", "updatedAt", "id"], + "isUnique": false + }, + "is_public_idx": { + "name": "is_public_idx", + "columns": ["public"], + "isUnique": false + }, + "folder_id_idx": { + "name": "folder_id_idx", + "columns": ["folderId"], + "isUnique": false + }, + "storage_integration_id_idx": { + "name": "storage_integration_id_idx", + "columns": ["storageIntegrationId"], + "isUnique": false + }, + "org_owner_folder_idx": { + "name": "org_owner_folder_idx", + "columns": ["orgId", "ownerId", "folderId"], + "isUnique": false + }, + "org_owner_folder_effective_idx": { + "name": "org_owner_folder_effective_idx", + "columns": ["orgId", "ownerId", "folderId", "effectiveCreatedAt"], + "isUnique": false + }, + "org_effective_created_idx": { + "name": "org_effective_created_idx", + "columns": ["orgId", "effectiveCreatedAt"], + "isUnique": false + }, + "screenshot_transcription_created_idx": { + "name": "screenshot_transcription_created_idx", + "columns": ["isScreenshot", "transcriptionStatus", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "videos_storageIntegrationId_storage_integrations_id_fk": { + "name": "videos_storageIntegrationId_storage_integrations_id_fk", + "tableFrom": "videos", + "tableTo": "storage_integrations", + "columnsFrom": ["storageIntegrationId"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "videos_id": { + "name": "videos_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": { + "integration_object_key_prefix_idx": { + "columns": { + "`objectKey`(191)": { + "isExpression": true + } + } + } + } + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 9f14cd20c63..d5f56c42e87 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1794461929204, "tag": "0055_developer_transcripts", "breakpoints": true + }, + { + "idx": 56, + "version": "5", + "when": 1794532188306, + "tag": "0056_developer_billing_read_indexes", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index b5570f7cae7..68a82dacc0c 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -2002,6 +2002,20 @@ export const developerTranscripts = mysqlTable( table.status, table.updatedAt, ), + index("dev_transcripts_app_completed_idx").on( + table.appId, + table.status, + table.completedAt, + table.durationSeconds, + table.billedMicroCredits, + ), + index("dev_transcripts_app_status_held_idx").on( + table.appId, + table.status, + table.createdAt, + table.deletedAt, + table.reservedMicroCredits, + ), ], ); @@ -2035,6 +2049,7 @@ export const developerCreditAccounts = mysqlTable( (table) => [ uniqueIndex("app_id_unique").on(table.appId), index("owner_idx").on(table.ownerId), + index("auto_top_up_idx").on(table.autoTopUpEnabled), ], ); @@ -2129,6 +2144,7 @@ export const developerCreditTransactions = mysqlTable( table.referenceId, table.referenceType, ), + index("reference_type_id_idx").on(table.referenceType, table.referenceId), ], ); diff --git a/packages/web-backend/src/AgentManagement.ts b/packages/web-backend/src/AgentManagement.ts index e2f0d8273b3..8298434efad 100644 --- a/packages/web-backend/src/AgentManagement.ts +++ b/packages/web-backend/src/AgentManagement.ts @@ -7,7 +7,7 @@ import { type User, type Video, } from "@cap/web-domain"; -import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm"; +import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm"; import { Effect } from "effect"; import { Database } from "./Database.ts"; @@ -649,7 +649,15 @@ export class AgentManagement extends Effect.Service()( Db.developerDailyStorageSnapshots.totalDurationMinutes, }) .from(Db.developerDailyStorageSnapshots) - .where(eq(Db.developerDailyStorageSnapshots.appId, appId)) + .where( + and( + eq(Db.developerDailyStorageSnapshots.appId, appId), + gt( + Db.developerDailyStorageSnapshots.totalDurationMinutes, + 0, + ), + ), + ) .orderBy(desc(Db.developerDailyStorageSnapshots.snapshotDate)) .limit(1), ]),