diff --git a/apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts b/apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts new file mode 100644 index 0000000000..7674786a5d --- /dev/null +++ b/apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts @@ -0,0 +1,176 @@ +/** + * /api/app-hosting/apps/[appId]/dedicated — buy or cancel the flat monthly + * always-on SKU for one published app. + * + * POST starts a dedicated subscription and returns a PaymentElement client + * secret. The app does NOT become dedicated here — entitlement begins when + * Stripe reports the subscription active, via the webhook. + * DELETE cancels at period end. The app stays dedicated until the period Stripe + * has already been paid for actually ends, and the webhook moves the tier. + * + * ONLY THE DRIVE OWNER MAY BUY. Hosting is billed to the drive owner + * (`resolveEnvPayerId` semantics — see `app-billing.ts`), so anybody else buying + * would be committing a recurring charge to somebody else's card. That makes + * ownership the authorization question here rather than the drive's usual + * edit-permission question, and it is checked against `drives.ownerId` directly + * rather than through a role: a role can be granted, and "may spend this person's + * money" is not something a role should be able to grant. + * + * Dark behind `APP_HOSTING_ENABLED`, and inert where `isBillingEnabled()` is false + * (tenant, onprem) — both checked inside `isDedicatedTierPurchasable()`, before any + * Stripe call. A disabled deployment answers 404, not 403: the feature does not + * exist there, and saying "forbidden" would advertise one that does. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { db } from '@pagespace/db/db'; +import { eq } from '@pagespace/db/operators'; +import { users } from '@pagespace/db/schema/auth'; +import { auditRequest } from '@pagespace/lib/audit/audit-log'; +import { loggers } from '@pagespace/lib/logging/logger-config'; +import { lookupDriveOwnerId } from '@pagespace/lib/billing/sandbox-payer'; +import { getPublishedApp } from '@pagespace/lib/services/app-hosting/provisioner'; +import { isDedicatedTierPurchasable } from '@pagespace/lib/services/app-hosting/dedicated-tier-service'; +import { + cancelDedicatedSubscription, + startDedicatedSubscription, +} from '@/lib/app-hosting/dedicated-subscription'; + +const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true }; + +/** Refusals that are the caller's fault, mapped to the status that says so. */ +const REFUSAL_STATUS: Record = { + guest_preset_not_allowed: 400, + already_subscribed: 409, + not_subscribed: 404, + // Everything price-shaped is a DEPLOYMENT misconfiguration, not a bad request: + // the customer asked for a legitimate size and this deployment cannot sell it. + // 503 rather than 500 because it is a configuration state that will change + // without a code fix, and rather than 400 because there is nothing the caller + // could have sent that would have worked. + price_not_configured: 503, + price_not_found: 503, + price_not_monthly_usd: 503, + price_below_floor: 503, + unknown_preset: 400, +}; + +/** + * Resolve the app and confirm the caller owns the drive that pays for it. + * + * Returns the app plus the OWNER's user row — not the caller's — because the + * subscription is created against the owner's Stripe customer, and they are the + * same person by the time this returns. Reading it explicitly keeps that fact in + * the code rather than in a reader's head. + */ +async function authorize(request: NextRequest, appId: string) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) return { error: auth.error } as const; + + // The kill switch first, before any read — while hosting is dark this endpoint + // must be inert rather than merely fruitless. + if (!isDedicatedTierPurchasable()) { + return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const; + } + + const app = await getPublishedApp(appId); + if (!app) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const; + + const ownerId = await lookupDriveOwnerId(app.driveId); + if (!ownerId || ownerId !== auth.userId) { + // 404, not 403: a non-owner must not be able to confirm that an app id exists + // by the shape of the refusal. + return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const; + } + + const [owner] = await db.select().from(users).where(eq(users.id, ownerId)).limit(1); + if (!owner) { + return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } as const; + } + + return { app, owner, userId: auth.userId } as const; +} + +export async function POST(request: NextRequest, context: { params: Promise<{ appId: string }> }) { + const { appId } = await context.params; + const authorized = await authorize(request, appId); + if ('error' in authorized) return authorized.error; + const { app, owner, userId } = authorized; + + // The size being bought is the size the app ALREADY RUNS, read from the row + // rather than taken from the request body. A body-supplied preset would let a + // caller buy the price of a small guest for an app running a large one — the + // two columns that must agree (`published_apps.guestPreset` and the + // subscription's `guestPreset`) would be set from different sources, which is + // precisely the drift the mirror table's docblock warns about. Resizing is a + // separate action, and it happens before the purchase. + try { + const result = await startDedicatedSubscription({ + publishedAppId: app.id, + user: owner, + guestPreset: app.guestPreset, + }); + + if (!result.ok) { + const status = REFUSAL_STATUS[result.reason] ?? 400; + return NextResponse.json({ error: result.reason }, { status }); + } + + auditRequest(request, { + eventType: 'data.write', + userId, + resourceType: 'published_app_subscription', + resourceId: result.stripeSubscriptionId, + details: { action: 'create', publishedAppId: app.id, guestPreset: app.guestPreset }, + }); + + return NextResponse.json({ + subscriptionId: result.stripeSubscriptionId, + clientSecret: result.clientSecret, + status: result.status, + }); + } catch (error) { + loggers.api.error( + 'Dedicated hosting subscription could not be started', + error instanceof Error ? error : undefined, + { publishedAppId: app.id }, + ); + return NextResponse.json({ error: 'Failed to start dedicated hosting' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, context: { params: Promise<{ appId: string }> }) { + const { appId } = await context.params; + const authorized = await authorize(request, appId); + if ('error' in authorized) return authorized.error; + const { app, userId } = authorized; + + try { + const result = await cancelDedicatedSubscription(app.id); + if (!result.ok) { + const status = REFUSAL_STATUS[result.reason] ?? 400; + return NextResponse.json({ error: result.reason }, { status }); + } + + auditRequest(request, { + eventType: 'data.write', + userId, + resourceType: 'published_app_subscription', + resourceId: app.id, + details: { action: 'cancel_at_period_end', publishedAppId: app.id }, + }); + + return NextResponse.json({ + cancelAtPeriodEnd: result.cancelAtPeriodEnd, + currentPeriodEnd: result.currentPeriodEnd.toISOString(), + }); + } catch (error) { + loggers.api.error( + 'Dedicated hosting subscription could not be cancelled', + error instanceof Error ? error : undefined, + { publishedAppId: app.id }, + ); + return NextResponse.json({ error: 'Failed to cancel dedicated hosting' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/cron/meter-published-apps/route.ts b/apps/web/src/app/api/cron/meter-published-apps/route.ts index c2b0ff888c..f0c9b3573d 100644 --- a/apps/web/src/app/api/cron/meter-published-apps/route.ts +++ b/apps/web/src/app/api/cron/meter-published-apps/route.ts @@ -2,6 +2,10 @@ import { defaultAwakeMeterDeps, meterAwakePublishedAppsSerialized, } from '@pagespace/lib/services/app-hosting/awake-meter'; +import { + DEDICATED_DUNNING_VISIBILITY_DAYS, + surveyDedicatedDunning, +} from '@pagespace/lib/services/app-hosting/dedicated-tier-service'; import * as Sentry from '@sentry/nextjs'; import { audit } from '@pagespace/lib/audit/audit-log'; import { loggers } from '@pagespace/lib/logging/logger-config'; @@ -32,6 +36,16 @@ import { validateSignedCronRequest } from '@/lib/auth/cron-auth'; * self-correcting: the window stays open and the next tick bills it in full, so * they are counted and audited rather than alerted on. * + * IT ALSO COUNTS THE OTHER TIER'S ONE INVISIBLE COST. A dedicated app keeps + * serving while its subscription is `past_due`, because taking a customer's + * production app offline over a card that will retry successfully is an outage + * they did not cause. That trade is bounded by Stripe's dunning ending the + * subscription — a STRIPE ACCOUNT SETTING, not code — so an account configured to + * leave failures `past_due` forever would serve an always-on machine free forever + * with nothing in this repo able to notice. `surveyDedicatedDunning` is what + * notices. It never fails the tick: it is a visibility signal about a decision we + * made deliberately, not money going wrong, so it warns and counts. + * * As with the storage reconcile, the Sentry capture is what actually reaches a * human — the docker cron invokes this through `curl -sS` without `-f`, so an * HTTP 500 exits 0 and its body just lands in a log. The status code stays the @@ -54,6 +68,41 @@ export async function GET(request: Request) { try { const run = await meterAwakePublishedAppsSerialized(defaultAwakeMeterDeps); + // Independent of the meter's outcome, and deliberately never allowed to break + // it: this reports on the DEDICATED tier, which the meter above does not touch + // at all. A failure to count is not a reason to fail a tick that billed + // correctly. + const dunning = await surveyDedicatedDunning().catch((error) => { + loggers.system.error('[Cron] Dedicated-tier dunning survey failed', error as Error); + return null; + }); + + if (dunning && dunning.pastDueStale > 0) { + console.log( + `[Cron] Dedicated hosting: ${dunning.pastDueStale} of ${dunning.pastDue} past_due app(s) overdue more than ${DEDICATED_DUNNING_VISIBILITY_DAYS} days — apps: ${dunning.staleAppIds.join(', ')}`, + ); + Sentry.captureMessage( + `Dedicated hosting: ${dunning.pastDueStale} always-on app(s) served on an unpaid subscription for more than ${DEDICATED_DUNNING_VISIBILITY_DAYS} days`, + { + // WARNING, not error. Nothing is broken and nothing is being lost to a + // bug — this is the cost of a deliberate product choice becoming + // unbounded, which is an operator decision (chase the customer, or fix + // the Stripe dunning settings), not an incident. + level: 'warning', + // Fingerprinted on the cause so a persistent situation stays ONE issue + // rather than opening a fresh one on every tick as the count moves. + fingerprint: ['dedicated-hosting-dunning-stale'], + tags: { check: 'published_app_dedicated_dunning' }, + extra: { + pastDue: dunning.pastDue, + pastDueStale: dunning.pastDueStale, + staleAppIds: dunning.staleAppIds, + thresholdDays: DEDICATED_DUNNING_VISIBILITY_DAYS, + }, + }, + ); + } + if (run.outcome === 'lock_busy') { console.log('[Cron] Published-app awake meter: skipped — advisory lock held by another run'); return NextResponse.json({ success: true, outcome: 'lock_busy', timestamp: new Date().toISOString() }); @@ -101,6 +150,10 @@ export async function GET(request: Request) { settledButUnadvanced: run.settledButUnadvanced, totalAwakeSeconds: run.totalAwakeSeconds, sourceFailed: run.sourceFailed, + // The dedicated tier's own figures. Zero on every deployment that has not + // sold one, which is all of them while the feature is dark. + dedicatedPastDue: dunning?.pastDue ?? 0, + dedicatedPastDueStale: dunning?.pastDueStale ?? 0, }, }); @@ -152,7 +205,13 @@ export async function GET(request: Request) { ); } - return NextResponse.json({ success: true, ...run, timestamp: new Date().toISOString() }); + return NextResponse.json({ + success: true, + ...run, + dedicatedPastDue: dunning?.pastDue ?? 0, + dedicatedPastDueStale: dunning?.pastDueStale ?? 0, + timestamp: new Date().toISOString(), + }); } catch (error) { loggers.system.error('[Cron] Error metering published-app awake seconds', error as Error); return NextResponse.json( diff --git a/apps/web/src/app/api/stripe/webhook/__tests__/dedicated-clobber.test.ts b/apps/web/src/app/api/stripe/webhook/__tests__/dedicated-clobber.test.ts new file mode 100644 index 0000000000..b9959aec49 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/__tests__/dedicated-clobber.test.ts @@ -0,0 +1,380 @@ +/** + * THE CLOBBER GUARD. + * + * A dedicated-hosting subscription and an account plan are both + * `customer.subscription.*` events on the same Stripe customer, and the account + * handler assumes every one of them IS that customer's plan. If a hosting + * subscription ever reaches `handleSubscriptionChange`, it derives an account tier + * from a price the tier map does not know — `free` — and writes that over a paying + * Pro / Founder / Business customer's `users.subscriptionTier`. The reconcile cron + * then reads their entitled-but-unmapped row as `indeterminate` and deliberately + * refuses to auto-repair it, so the demotion is permanent AND invisible to the + * machinery built for exactly that failure. The same fork protects the credit + * bucket: `applyStripeFunding` refills the monthly AI allowance on every paid + * subscription invoice, so an unforked hosting invoice grants a free refill every + * month on its own billing anchor. + * + * These tests are the proof that the fork holds, in both directions. Break + * `routeSubscription`/`routeInvoice` (or the `metadata.kind` they read) and the + * "leaves the tier untouched" / "does not refill" cases go red. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type Stripe from 'stripe'; + +const { mockConstructEvent, StripeError } = vi.hoisted(() => ({ + mockConstructEvent: vi.fn(), + StripeError: class extends Error {}, +})); +vi.mock('@/lib/stripe', () => ({ + stripe: { webhooks: { constructEvent: mockConstructEvent } }, + Stripe: { errors: { StripeError } }, + getTierFromPrice: vi.fn(() => 'free'), +})); + +/** + * A db double that records every UPDATE's SET payload, which is the only thing + * these tests need to observe: the clobber IS an update to `users`. + */ +const mockDb = vi.hoisted(() => { + const state: { + updateSets: Array>; + userRows: unknown[]; + /** Deletes of the `stripe_events` idempotency marker — the retry signal. */ + deletes: number; + } = { updateSets: [], userRows: [], deletes: 0 }; + const recordUpdate = () => ({ + set: (payload: Record) => { + state.updateSets.push(payload); + return { where: async () => undefined }; + }, + }); + return { + __state: state, + select: () => ({ + from: () => ({ where: () => ({ limit: async () => state.userRows }) }), + }), + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ returning: async () => [{ id: 'evt' }] }), + }), + }), + update: recordUpdate, + delete: () => ({ + where: async () => { + state.deletes += 1; + }, + }), + transaction: async (cb: (tx: unknown) => Promise) => { + await cb({ + insert: () => ({ values: () => ({ onConflictDoUpdate: async () => undefined }) }), + update: recordUpdate, + }); + }, + }; +}); +vi.mock('@pagespace/db/db', () => ({ db: mockDb })); +vi.mock('@pagespace/db/operators', () => ({ + eq: (a: unknown, b: unknown) => ({ a, b }), + and: (...c: unknown[]) => ({ c }), + isNull: (a: unknown) => ({ a }), + lte: (a: unknown, b: unknown) => ({ a, b }), +})); +vi.mock('@pagespace/db/schema/auth', () => ({ users: { id: 'users.id' } })); +vi.mock('@pagespace/db/schema/subscriptions', () => ({ subscriptions: {}, stripeEvents: {} })); + +vi.mock('@pagespace/lib/logging/logger-config', () => ({ + loggers: { + api: { error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + auth: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + }, + logger: { child: vi.fn(() => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })) }, +})); + +const mockApplyStripeFunding = vi.hoisted(() => vi.fn()); +vi.mock('@pagespace/lib/billing/credit-funding', () => ({ applyStripeFunding: mockApplyStripeFunding })); +vi.mock('@/lib/billing/send-payment-receipt-email', () => ({ + sendSubscriptionReceiptEmail: vi.fn(), + sendTopupReceiptEmail: vi.fn(), +})); +vi.mock('@/lib/subscription/credit-balance', () => ({ emitCreditsUpdated: vi.fn() })); + +const mockCaptureMessage = vi.hoisted(() => vi.fn()); +vi.mock('@sentry/nextjs', () => ({ + captureMessage: mockCaptureMessage, + captureException: vi.fn(), +})); + +/** + * The hosting service is mocked because it opens a database of its own. What it + * DOES with a dedicated event is tested in packages/lib; what matters here is + * only that the event reached it instead of the account handler. + */ +const { mockRecordSubscription, mockSyncTier, mockFindByStripeId } = vi.hoisted(() => { + /** + * The sync's real return is a union of outcomes, so the double is typed as the + * union rather than inferred from its default. Left to inference, the default + * `entitled` narrows the mock and a test that needs a DIFFERENT outcome — the + * revenue-leak signal below — fails to typecheck. + */ + type SyncOutcome = + | { outcome: 'entitled' | 'downgraded'; publishedAppId: string; tierChanged: boolean } + | { outcome: 'unknown_subscription' } + | { outcome: 'tier_change_refused'; publishedAppId: string; reason: string }; + return { + // The mirror write now returns the AUTHORITATIVE row alongside its outcome — + // the tier follows that row, never the event's own status, so an out-of-order + // event the mirror refused cannot move the tier anyway. + mockRecordSubscription: vi.fn(async (facts: { publishedAppId: string; status: string }) => ({ + outcome: 'applied' as const, + row: { publishedAppId: facts.publishedAppId, status: facts.status }, + })), + mockSyncTier: vi.fn( + async (): Promise => ({ outcome: 'entitled', publishedAppId: 'app_1', tierChanged: true }), + ), + mockFindByStripeId: vi.fn(async () => null), + }; +}); +vi.mock('@pagespace/lib/services/app-hosting/dedicated-tier-service', () => ({ + recordDedicatedSubscription: mockRecordSubscription, + syncAppTierToSubscription: mockSyncTier, + findDedicatedSubscriptionByStripeId: mockFindByStripeId, +})); + +import { POST } from '../route'; + +/** + * The wire value, written out rather than imported from `dedicated-tier.ts`. + * + * This string is a CONTRACT with Stripe: it is stamped onto live subscriptions + * and snapshotted onto every invoice they generate, so changing the constant + * would silently stop routing every subscription already sold. Importing it here + * would make this test agree with any change to it; spelling it out makes the test + * fail, which is the correct response to renaming a value that exists in another + * company's database. + */ +const DEDICATED_SUBSCRIPTION_KIND = 'published_app_dedicated'; + +const NOW = Math.floor(Date.now() / 1000); + +function subscription(metadata: Record | undefined): Stripe.Subscription { + return { + id: 'sub_hosting_1', + customer: 'cus_pro_1', + status: 'active', + cancel_at_period_end: false, + metadata, + items: { + data: [ + { + id: 'si_1', + price: { id: 'price_dedicated_1', unit_amount: 10061 }, + current_period_start: NOW, + current_period_end: NOW + 2_592_000, + }, + ], + }, + } as unknown as Stripe.Subscription; +} + +function invoice(metadata: Record | undefined): Stripe.Invoice { + return { + id: 'in_1', + customer: 'cus_pro_1', + amount_paid: 10061, + lines: { data: [{ pricing: { price_details: { price: 'price_dedicated_1' } } }] }, + parent: metadata + ? { subscription_details: { subscription: 'sub_hosting_1', metadata } } + : null, + } as unknown as Stripe.Invoice; +} + +function post(type: string, object: unknown) { + mockConstructEvent.mockReturnValue({ + id: `evt_${type}_${Math.random()}`, + type, + data: { object }, + }); + return POST( + new Request('https://pagespace.ai/api/stripe/webhook', { + method: 'POST', + headers: { 'stripe-signature': 'sig' }, + body: '{}', + }) as never, + ); +} + +/** Every SET payload that touched the account tier. */ +const tierWrites = () => mockDb.__state.updateSets.filter((s) => 'subscriptionTier' in s); + +beforeEach(() => { + vi.clearAllMocks(); + mockDb.__state.updateSets.length = 0; + mockDb.__state.deletes = 0; + // A PAYING customer. This is the person the clobber would demote. + mockDb.__state.userRows = [{ id: 'user_pro_1', subscriptionTier: 'pro', email: 'pro@example.com', name: 'Pro' }]; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; +}); + +describe('a dedicated hosting subscription', () => { + const hostingMetadata = { + kind: DEDICATED_SUBSCRIPTION_KIND, + publishedAppId: 'app_1', + userId: 'user_pro_1', + guestPreset: 'shared-cpu-1x-512', + }; + + it('leaves a paying customer’s account tier untouched', async () => { + const response = await post('customer.subscription.created', subscription(hostingMetadata)); + expect(response.status).toBe(200); + expect( + tierWrites(), + 'a hosting subscription must never write users.subscriptionTier', + ).toEqual([]); + }); + + it('leaves the tier untouched when the hosting subscription is CANCELLED too', async () => { + // The deleted event is the more dangerous half: `handleSubscriptionDeleted` + // sets the tier to `free` unconditionally, so an unforked cancel of a hosting + // add-on would demote the customer's plan. + const response = await post('customer.subscription.deleted', subscription(hostingMetadata)); + expect(response.status).toBe(200); + expect(tierWrites()).toEqual([]); + }); + + it('is handed to the hosting handler instead', async () => { + await post('customer.subscription.updated', subscription(hostingMetadata)); + expect(mockSyncTier).toHaveBeenCalledWith( + expect.objectContaining({ publishedAppId: 'app_1', status: 'active' }), + ); + }); +}); + +describe('a dedicated hosting invoice', () => { + const hostingMetadata = { kind: DEDICATED_SUBSCRIPTION_KIND }; + + it('does not refill the customer’s monthly AI credits', async () => { + // Otherwise a customer with a dedicated app gets a second monthly allowance + // every month, on the hosting subscription's own billing anchor. + const response = await post('invoice.paid', invoice(hostingMetadata)); + expect(response.status).toBe(200); + expect(mockApplyStripeFunding).not.toHaveBeenCalled(); + }); + + it('does not downgrade anything on a failed payment', async () => { + const response = await post('invoice.payment_failed', invoice(hostingMetadata)); + expect(response.status).toBe(200); + expect(tierWrites()).toEqual([]); + }); +}); + +describe('the existing account-plan path is unchanged', () => { + it('still writes the tier for a subscription carrying NO metadata', async () => { + // Fail-closed means THE OLD BEHAVIOUR. Every subscription written before this + // discriminator existed has no `kind`, and must keep taking this path. + const response = await post('customer.subscription.created', subscription(undefined)); + expect(response.status).toBe(200); + expect(tierWrites().length).toBe(1); + }); + + it('still writes the tier for an UNRECOGNISED kind', async () => { + // Diverting an unknown kind would mean a typo silently stops maintaining a + // real customer's tier, with nothing left for the reconcile cron to repair. + const response = await post( + 'customer.subscription.created', + subscription({ kind: 'something_we_have_never_shipped' }), + ); + expect(response.status).toBe(200); + expect(tierWrites().length).toBe(1); + expect(mockSyncTier).not.toHaveBeenCalled(); + }); + + it('still refills credits for an ordinary subscription invoice', async () => { + const response = await post('invoice.paid', invoice(undefined)); + expect(response.status).toBe(200); + expect(mockApplyStripeFunding).toHaveBeenCalled(); + }); +}); + +describe('a dedicated hosting event that throws', () => { + const hostingMetadata = { + kind: DEDICATED_SUBSCRIPTION_KIND, + publishedAppId: 'app_1', + userId: 'user_pro_1', + guestPreset: 'shared-cpu-1x-512', + }; + + it('clears the idempotency marker and asks Stripe to redeliver', async () => { + // Without this, a throwing hosting event is still marked processed, Stripe's + // redelivery classifies as a duplicate and is acked, and an app that had just + // been set `dedicated` stays that way forever with no paying subscription + // behind it and nothing left to repair it. + mockSyncTier.mockRejectedValueOnce(new Error('database unavailable')); + + const response = await post('customer.subscription.updated', subscription(hostingMetadata)); + + expect(response.status, 'a 500 is what makes Stripe redeliver').toBe(500); + expect( + mockDb.__state.deletes, + 'the claimed event id must be released so the redelivery is not duplicate-acked', + ).toBeGreaterThan(0); + }); + + it('processes the event normally on the redelivery', async () => { + mockSyncTier.mockRejectedValueOnce(new Error('database unavailable')); + await post('customer.subscription.updated', subscription(hostingMetadata)); + + mockDb.__state.deletes = 0; + const retry = await post('customer.subscription.updated', subscription(hostingMetadata)); + + expect(retry.status).toBe(200); + expect(mockSyncTier).toHaveBeenCalledTimes(2); + expect(mockDb.__state.deletes, 'a successful reprocess releases nothing').toBe(0); + }); + + it('still leaves the account tier untouched on the failing attempt', async () => { + // The retry wrapper must not become a route back into the account handler. + mockSyncTier.mockRejectedValueOnce(new Error('database unavailable')); + await post('customer.subscription.updated', subscription(hostingMetadata)); + expect(tierWrites()).toEqual([]); + }); +}); + +describe('an app that cannot follow its subscription', () => { + const hostingMetadata = { + kind: DEDICATED_SUBSCRIPTION_KIND, + publishedAppId: 'app_1', + userId: 'user_pro_1', + guestPreset: 'shared-cpu-4x-4096', + }; + + it('raises an operator signal, because an unbilled always-on app is a revenue leak', async () => { + // The case: the subscription stopped paying, so the app should go back to + // metered — but it runs a guest the metered tier may not run, so the downgrade + // is refused rather than forced (forcing it would destroy and recreate the + // machine, taking a live app down as a side effect of a billing event). What + // is left is an always-on machine nobody is paying for, until a human acts. + mockSyncTier.mockResolvedValueOnce({ + outcome: 'tier_change_refused', + publishedAppId: 'app_1', + reason: 'guest_preset_not_allowed', + }); + + const response = await post('customer.subscription.deleted', subscription(hostingMetadata)); + + expect(response.status).toBe(200); + expect(mockCaptureMessage, 'a log line is not an operator signal').toHaveBeenCalledTimes(1); + const [, options] = mockCaptureMessage.mock.calls[0]; + expect(options.level).toBe('warning'); + // Fingerprinted on the CAUSE so a persistent situation stays one issue rather + // than opening a fresh one on every Stripe redelivery. + expect(options.fingerprint).toEqual(['dedicated-hosting-tier-change-refused']); + expect(options.extra.publishedAppId).toBe('app_1'); + expect(options.extra.reason).toBe('guest_preset_not_allowed'); + }); + + it('stays quiet when the tier followed normally', async () => { + await post('customer.subscription.updated', subscription(hostingMetadata)); + expect(mockCaptureMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/stripe/webhook/dedicated-handlers.ts b/apps/web/src/app/api/stripe/webhook/dedicated-handlers.ts new file mode 100644 index 0000000000..35d1f48ba2 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/dedicated-handlers.ts @@ -0,0 +1,132 @@ +/** + * dedicated-handlers — what the Stripe webhook does with a DEDICATED HOSTING + * subscription event, once `dedicated-routing.ts` has established that it is one. + * + * Two writes, in this order, and the order is the point: + * + * 1. MIRROR the subscription into `published_app_subscriptions`. Stripe is the + * source of truth for money; that table is our copy of it, and it is what the + * publish surface reads to say "renews on…" or "cancels on…". + * 2. MAKE THE APP'S TIER FOLLOW the subscription's status. Entitlement is a + * status question (`active` / `trialing`), never an existence one — a + * cancelled subscription keeps its mirror row, because the row is what + * explains WHY an app went back to metered. + * + * The mirror is written FIRST because the tier sync resolves the app THROUGH it. A + * `customer.subscription.created` that beat the purchase path's own mirror write + * would otherwise find nothing and no-op; writing here makes the webhook + * self-sufficient and makes the two orderings converge on the same state. + * + * NEVER THROWS FOR AN ORDINARY MISS. A subscription this deployment has no app for + * is acked, not retried: Stripe would redeliver forever against a row that is + * never going to appear (a subscription created against another environment's + * database is the common case in test mode). A genuine failure — the database is + * down — still propagates, and the webhook's own retry machinery handles it. + */ + +import * as Sentry from '@sentry/nextjs'; +import type { Stripe } from '@/lib/stripe'; +import { loggers } from '@pagespace/lib/logging/logger-config'; +import { + findDedicatedSubscriptionByStripeId, + recordDedicatedSubscription, + syncAppTierToSubscription, +} from '@pagespace/lib/services/app-hosting/dedicated-tier-service'; +import { subscriptionPeriod } from '@/lib/app-hosting/stripe-subscription-period'; + +export async function handleDedicatedSubscriptionEvent( + subscription: Stripe.Subscription, + eventId: string, + /** `event.created`, in seconds — the ordering stamp the mirror guards on. */ + eventCreated: number, +): Promise { + const publishedAppId = subscription.metadata?.publishedAppId; + const userId = subscription.metadata?.userId; + const guestPreset = subscription.metadata?.guestPreset; + + // Prefer the metadata the purchase path stamped; fall back to the existing + // mirror row when an event carries a partial bag (metadata can be edited in the + // Stripe dashboard, and a human editing one key should not be able to orphan the + // subscription). Only if BOTH are empty is there nothing to act on. + const existing = await findDedicatedSubscriptionByStripeId(subscription.id); + const resolvedAppId = publishedAppId ?? existing?.publishedAppId; + const resolvedUserId = userId ?? existing?.userId; + const resolvedPreset = guestPreset ?? existing?.guestPreset; + const priceId = subscription.items?.data?.[0]?.price?.id ?? existing?.stripePriceId; + + if (!resolvedAppId || !resolvedUserId || !resolvedPreset || !priceId) { + loggers.api.warn('Dedicated hosting subscription event could not be resolved to an app; acking', { + eventId, + stripeSubscriptionId: subscription.id, + hasMetadataAppId: Boolean(publishedAppId), + hasMirrorRow: Boolean(existing), + }); + return; + } + + const period = subscriptionPeriod(subscription); + const mirror = await recordDedicatedSubscription({ + publishedAppId: resolvedAppId, + userId: resolvedUserId, + stripeSubscriptionId: subscription.id, + stripePriceId: priceId, + guestPreset: resolvedPreset, + status: subscription.status, + currentPeriodStart: period.start, + currentPeriodEnd: period.end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + // Stripe does not order webhook deliveries, so the event's OWN timestamp is + // what lets the mirror refuse a message from before the one it already + // applied. `event.created` is in seconds. + stripeEventCreated: new Date(eventCreated * 1000), + }); + + // THE TIER FOLLOWS THE MIRROR, NOT THIS EVENT. The write above can refuse an + // out-of-order event — a late `active` after a cancellation — and in that case + // `mirror.row` still holds the status we believe. Syncing from + // `subscription.status` here would re-entitle the app from the very message the + // guard just rejected: the write would be refused and the tier would move + // anyway, which is worse than no guard because it looks defended. + const outcome = await syncAppTierToSubscription(mirror.row); + loggers.api.info('Dedicated hosting subscription synced', { + eventId, + stripeSubscriptionId: subscription.id, + eventStatus: subscription.status, + mirrorOutcome: mirror.outcome, + mirrorStatus: mirror.row?.status, + outcome: outcome.outcome, + publishedAppId: resolvedAppId, + }); + + if (outcome.outcome === 'tier_change_refused') { + // A PURE REVENUE LEAK, and it needs an operator rather than a log line. + // + // The case that produces it: a subscription stops paying, so the app should go + // back to metered — but it is running a guest size the metered tier may not + // run (`published_apps_metered_guest_preset`, because the awake meter prices + // one fixed shape). The downgrade is refused rather than forced, deliberately: + // resizing means destroying and recreating the machine, and a webhook must not + // take a live app down as a side effect of a billing event. The consequence is + // an always-on machine nobody is paying for, and it persists until a human + // resizes or stops it — so a human has to be told. + // + // Warning, not error, and fingerprinted on the CAUSE: nothing is broken, and a + // persistent situation should stay one issue rather than opening a fresh one on + // every redelivery. + Sentry.captureMessage( + `Dedicated hosting: app ${outcome.publishedAppId} could not follow its subscription (${outcome.reason}) — it may be always-on and unbilled`, + { + level: 'warning', + fingerprint: ['dedicated-hosting-tier-change-refused'], + tags: { check: 'published_app_dedicated_tier_sync' }, + extra: { + eventId, + publishedAppId: outcome.publishedAppId, + stripeSubscriptionId: subscription.id, + subscriptionStatus: subscription.status, + reason: outcome.reason, + }, + }, + ); + } +} diff --git a/apps/web/src/app/api/stripe/webhook/dedicated-routing.ts b/apps/web/src/app/api/stripe/webhook/dedicated-routing.ts new file mode 100644 index 0000000000..babe75e91a --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/dedicated-routing.ts @@ -0,0 +1,111 @@ +/** + * dedicated-routing — the webhook's fork between an ACCOUNT PLAN and a + * DEDICATED HOSTING subscription. + * + * ───────────────────────────────────────────────────────────────────────────── + * WHY A FORK IS NEEDED AT ALL, stated once so nobody removes it as ceremony. + * + * The account-plan handlers in `route.ts` assume every subscription on a customer + * IS that customer's plan. `handleSubscriptionChange` derives a tier from the one + * subscription in the event and writes it to `users.subscriptionTier`; the + * `invoice.paid` path refills the customer's monthly AI-credit bucket. Both are + * correct for the only kind of subscription that has ever existed here, and both + * are wrong for a hosting charge: + * + * - a hosting subscription carries a price the tier map does not know, so the + * tier derives to `free` and a paying Pro/Founder/Business customer is + * DEMOTED by buying more. The reconcile cron then reads their unmapped + * entitled row as `indeterminate` and deliberately refuses to auto-repair it, + * so the demotion is permanent and invisible to the machinery built for + * exactly that failure. + * - a hosting invoice paid on its own billing anchor would REFILL the monthly + * credit allowance a second time each month — free credits, silently, forever. + * + * So hosting subscriptions are stamped `metadata.kind = 'published_app_dedicated'` + * at create, and this module is where that stamp is read. + * ───────────────────────────────────────────────────────────────────────────── + * + * FAIL CLOSED MEANS "THE OLD BEHAVIOUR", NOT "NO BEHAVIOUR". Every subscription + * that exists today has no `kind` at all, and must keep taking the existing path + * byte-identically — so an absent discriminator classifies as `account_plan`, not + * as something to quarantine. An UNRECOGNISED `kind` is logged and then also takes + * the account path: dropping it instead would mean a typo'd or future value on a + * real account subscription silently stops maintaining that customer's tier, with + * nothing left for the reconcile cron to repair from. The only thing diverted is + * a value we positively recognise. + */ + +import type { Stripe } from '@/lib/stripe'; +import { loggers } from '@pagespace/lib/logging/logger-config'; +import { + classifySubscriptionKind, + type StripeSubscriptionKind, +} from '@pagespace/lib/services/app-hosting/dedicated-tier'; + +/** Stripe metadata, narrowed to what the classifier reads. */ +type MetadataBag = Record | null | undefined; + +/** + * Classify one subscription, logging an unrecognised `kind` on the way past. + * + * The log is the whole value of distinguishing `unknown` from `account_plan`: + * behaviourally they are the same (both take the existing path), but only one of + * them means somebody wrote a discriminator this code has never been taught. + */ +export function routeSubscription( + subscription: Stripe.Subscription, + eventId: string, +): StripeSubscriptionKind { + const kind = classifySubscriptionKind(subscription.metadata as MetadataBag); + if (kind === 'unknown') { + loggers.api.warn( + 'Stripe subscription carries an unrecognised metadata kind; handling it as an account plan', + { eventId, subscriptionId: subscription.id, kind: subscription.metadata?.kind }, + ); + } + return kind; +} + +/** + * Classify one INVOICE, by the subscription metadata Stripe snapshots onto it. + * + * `invoice.parent.subscription_details.metadata` is an immutable copy of the + * subscription's metadata taken at invoice finalization (Stripe populates it for + * invoices created on or after 2023-06-29). Reading the snapshot rather than + * looking the subscription up is deliberate: it needs no API call on the hot + * webhook path, and it describes the subscription AS IT WAS BILLED, which is the + * thing the funding decision is actually about. + * + * An invoice with no subscription parent at all — a one-off credit-pack payment, + * a manual invoice — has no metadata to read and classifies as `account_plan`, + * which routes it exactly where it goes today. + */ +export function routeInvoice(invoice: Stripe.Invoice, eventId: string): StripeSubscriptionKind { + const parent = invoice.parent; + const metadata = parent?.subscription_details?.metadata as MetadataBag; + const kind = classifySubscriptionKind(metadata); + if (kind === 'unknown') { + loggers.api.warn( + 'Stripe invoice carries an unrecognised subscription metadata kind; handling it as an account plan', + { eventId, invoiceId: invoice.id, kind: metadata?.kind }, + ); + } + return kind; +} + +/** + * The subscription id an invoice was generated from, or null. + * + * Lives here rather than inline because the field has moved with the API version + * and the shape is easy to get subtly wrong: it can be an id string OR an expanded + * `Subscription` object, and a caller that assumed the string would silently + * stringify an object into a lookup that matches nothing. + */ +export function invoiceSubscriptionId(invoice: Stripe.Invoice): string | null { + const subscription = invoice.parent?.subscription_details?.subscription; + if (typeof subscription === 'string') return subscription; + if (subscription && typeof subscription === 'object' && typeof subscription.id === 'string') { + return subscription.id; + } + return null; +} diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index e622526261..d57739d99c 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -15,6 +15,8 @@ import { getCreditPack } from '@pagespace/lib/billing/credit-pricing'; import { emitCreditsUpdated } from '@/lib/subscription/credit-balance'; import { sendSubscriptionReceiptEmail, sendTopupReceiptEmail } from '@/lib/billing/send-payment-receipt-email'; import { classifyDedupeOutcome, DEFAULT_LEASE_MS, type DedupeOutcome } from './dedupe'; +import { invoiceSubscriptionId, routeInvoice, routeSubscription } from './dedicated-routing'; +import { handleDedicatedSubscriptionEvent } from './dedicated-handlers'; export async function POST(request: NextRequest) { try { @@ -143,13 +145,52 @@ export async function POST(request: NextRequest) { try { switch (event.type) { case 'customer.subscription.created': - case 'customer.subscription.updated': - await handleSubscriptionChange(event.data.object as Stripe.Subscription); + case 'customer.subscription.updated': { + // FORKED ON `metadata.kind`. A dedicated-hosting subscription must never + // reach `handleSubscriptionChange`, which would derive an account tier of + // `free` from its unmapped price and write it over a paying customer's + // tier — permanently, because the reconcile cron then reads that entitled + // unmapped row as `indeterminate` and refuses to repair it. See + // `dedicated-routing.ts` for why an absent or unrecognised kind still + // takes the path below. + const subscription = event.data.object as Stripe.Subscription; + if (routeSubscription(subscription, event.id) === 'published_app_dedicated') { + // RETRYABLE, through the same marker-deleting wrapper the funding + // paths use. Without it a throwing hosting event is still marked + // processed, so Stripe's redelivery classifies as a duplicate and is + // acked — and an app that had just been set `dedicated` would stay + // that way forever with no paying subscription behind it and nothing + // left to repair it. The handler is idempotent (an upsert plus a tier + // write guarded on the tier it planned against), so reprocessing is + // safe. + await withFundingRetry(event.id, () => + handleDedicatedSubscriptionEvent(subscription, event.id, event.created), + ); + break; + } + await handleSubscriptionChange(subscription); break; + } - case 'customer.subscription.deleted': - await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + case 'customer.subscription.deleted': { + // Same fork. A cancelled hosting subscription downgrades ONE APP back to + // metered; it says nothing about the account's plan, and running it + // through `handleSubscriptionDeleted` would set the customer's tier to + // `free` because a hosting subscription ended. + const subscription = event.data.object as Stripe.Subscription; + if (routeSubscription(subscription, event.id) === 'published_app_dedicated') { + // Retryable for the same reason as the created/updated fork above, and + // more urgently: this is the event that takes an app OFF the dedicated + // tier, so losing it to a duplicate-ack leaves an always-on machine + // running for a customer who has stopped paying. + await withFundingRetry(event.id, () => + handleDedicatedSubscriptionEvent(subscription, event.id, event.created), + ); + break; + } + await handleSubscriptionDeleted(subscription); break; + } case 'checkout.session.completed': { // Whole path is retryable: a transient failure in EITHER the handler or @@ -202,12 +243,44 @@ export async function POST(request: NextRequest) { break; } - case 'invoice.payment_failed': - await handlePaymentFailed(event.data.object as Stripe.Invoice); + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice; + if (routeInvoice(invoice, event.id) === 'published_app_dedicated') { + // Logged and left to Stripe's dunning. The app is NOT downgraded on a + // failed payment: Stripe retries for days, and taking an always-on app + // to scale-to-zero over a card that will probably work on the next + // attempt is an outage caused by a temporary decline. Entitlement ends + // when the subscription's STATUS ends (`canceled` / `unpaid`), which + // arrives as a subscription event and is handled there. + loggers.api.info('Dedicated hosting invoice payment failed; awaiting Stripe dunning', { + eventId: event.id, + invoiceId: invoice.id, + stripeSubscriptionId: invoiceSubscriptionId(invoice), + }); + break; + } + await handlePaymentFailed(invoice); break; + } case 'invoice.paid': { const invoice = event.data.object as Stripe.Invoice; + // FORKED BEFORE FUNDING, and this fork is as load-bearing as the + // subscription one. `applyStripeFunding` refills the customer's monthly + // AI-credit bucket on every paid subscription invoice, falling back to + // their STORED tier when the invoice's price maps to no tier — which is + // exactly what a hosting price does. Left unforked, a customer with a + // dedicated app would have their monthly allowance reset a second time + // every month, on the hosting subscription's own billing anchor. + if (routeInvoice(invoice, event.id) === 'published_app_dedicated') { + loggers.api.info('Dedicated hosting invoice paid', { + eventId: event.id, + invoiceId: invoice.id, + stripeSubscriptionId: invoiceSubscriptionId(invoice), + amountPaid: invoice.amount_paid, + }); + break; + } await withFundingRetry(event.id, async () => { await handleInvoicePaid(invoice); // Reset the monthly credit bucket to the tier allowance on each renewal. diff --git a/apps/web/src/lib/app-hosting/__tests__/dedicated-subscription.test.ts b/apps/web/src/lib/app-hosting/__tests__/dedicated-subscription.test.ts new file mode 100644 index 0000000000..6d3a0b3727 --- /dev/null +++ b/apps/web/src/lib/app-hosting/__tests__/dedicated-subscription.test.ts @@ -0,0 +1,239 @@ +/** + * Re-buying always-on after a subscription has ended. + * + * The mirror row OUTLIVES the subscription on purpose — it is what explains why an + * app went back to metered — so "has a row" and "is currently paying" are different + * questions, and the purchase path must ask the second one. Asking the first would + * mean a single abandoned checkout, or one ordinary cancellation, permanently + * bricks the SKU for that app: every future purchase answers `already_subscribed`, + * and the cancel escape hatch cannot help either, because cancelling an + * already-terminal Stripe subscription errors. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSubscriptionsCreate, mockPricesRetrieve } = vi.hoisted(() => ({ + mockSubscriptionsCreate: vi.fn(), + mockPricesRetrieve: vi.fn(), +})); +vi.mock('@/lib/stripe', () => ({ + stripe: { + subscriptions: { create: mockSubscriptionsCreate, update: vi.fn() }, + prices: { retrieve: mockPricesRetrieve }, + }, +})); +vi.mock('@/lib/stripe-customer', () => ({ getOrCreateStripeCustomer: vi.fn(async () => 'cus_1') })); +vi.mock('@pagespace/lib/logging/logger-config', () => ({ + loggers: { api: { warn: vi.fn(), error: vi.fn(), info: vi.fn() } }, +})); + +const { mockFindForApp, mockRecord, mockPurchasable } = vi.hoisted(() => ({ + mockFindForApp: vi.fn(), + mockRecord: vi.fn(async (facts: { publishedAppId: string; status: string }) => ({ + outcome: 'applied' as const, + row: { publishedAppId: facts.publishedAppId, status: facts.status }, + })), + mockPurchasable: vi.fn(() => true), +})); +vi.mock('@pagespace/lib/services/app-hosting/dedicated-tier-service', () => ({ + findDedicatedSubscriptionForApp: mockFindForApp, + recordDedicatedSubscription: mockRecord, + isDedicatedTierPurchasable: mockPurchasable, +})); + +import { startDedicatedSubscription } from '../dedicated-subscription'; + +const NOW = Math.floor(Date.now() / 1000); +const user = { id: 'user_1', email: 'a@b.c', name: 'A', stripeCustomerId: 'cus_1' }; + +function buy() { + return startDedicatedSubscription({ + publishedAppId: 'app_1', + user, + guestPreset: 'shared-cpu-1x-512', + }); +} + +/** A mirror row in whatever state the test needs. */ +const mirror = (status: string) => ({ + publishedAppId: 'app_1', + userId: 'user_1', + stripeSubscriptionId: 'sub_old', + stripePriceId: 'price_old', + guestPreset: 'shared-cpu-1x-512', + status, +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockPurchasable.mockReturnValue(true); + process.env.DEDICATED_PRICE_ID_SHARED_CPU_1X_512 = 'price_dedicated_1'; + // Comfortably above the derived floor, so price validation is never what a test + // below is actually measuring. + mockPricesRetrieve.mockResolvedValue({ + active: true, + currency: 'usd', + unit_amount: 50_000, + recurring: { interval: 'month', interval_count: 1 }, + }); + mockSubscriptionsCreate.mockResolvedValue({ + id: 'sub_new', + status: 'incomplete', + cancel_at_period_end: false, + items: { data: [{ id: 'si_1', current_period_start: NOW, current_period_end: NOW + 2_592_000 }] }, + latest_invoice: { confirmation_secret: { client_secret: 'cs_test' } }, + }); +}); + +describe('buying always-on when a live subscription already exists', () => { + it('refuses while the subscription is paying', async () => { + for (const status of ['active', 'trialing', 'past_due']) { + mockFindForApp.mockResolvedValue(mirror(status)); + const result = await buy(); + expect(result, `a ${status} subscription must block a second charge`).toEqual({ + ok: false, + reason: 'already_subscribed', + }); + } + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); + + it('refuses while a checkout is still open', async () => { + // The customer has a payment sheet in front of them right now; issuing a second + // subscription would let them pay twice for one app. + mockFindForApp.mockResolvedValue(mirror('incomplete')); + expect(await buy()).toEqual({ ok: false, reason: 'already_subscribed' }); + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); +}); + +describe('buying always-on again after the last subscription ended', () => { + it('succeeds after an ordinary cancellation', async () => { + mockFindForApp.mockResolvedValue(mirror('canceled')); + const result = await buy(); + expect(result.ok, 'a cancelled subscription must not brick the SKU for this app').toBe(true); + expect(mockSubscriptionsCreate).toHaveBeenCalled(); + }); + + it('succeeds after an abandoned checkout', async () => { + // The case that would otherwise be permanent: one customer starting a checkout + // and walking away leaves an `incomplete_expired` row forever. + mockFindForApp.mockResolvedValue(mirror('incomplete_expired')); + const result = await buy(); + expect(result.ok, 'an abandoned checkout must not brick the SKU for this app').toBe(true); + }); + + it('succeeds after an unpaid subscription', async () => { + mockFindForApp.mockResolvedValue(mirror('unpaid')); + expect((await buy()).ok).toBe(true); + }); + + it('succeeds when the app has never had one', async () => { + mockFindForApp.mockResolvedValue(null); + expect((await buy()).ok).toBe(true); + }); +}); + +describe('the purchase is refused before Stripe is touched', () => { + it('makes no Stripe call where billing is disabled', async () => { + mockPurchasable.mockReturnValue(false); + mockFindForApp.mockResolvedValue(null); + expect(await buy()).toEqual({ ok: false, reason: 'unavailable' }); + expect(mockPricesRetrieve).not.toHaveBeenCalled(); + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); + + it('makes no Stripe call for a size the dedicated tier may not run', async () => { + mockFindForApp.mockResolvedValue(null); + const result = await startDedicatedSubscription({ + publishedAppId: 'app_1', + user, + guestPreset: 'shared-cpu-64x-262144', + }); + expect(result).toEqual({ ok: false, reason: 'guest_preset_not_allowed' }); + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); + + it('makes no Stripe subscription for an unconfigured price', async () => { + // Fail-closed: a deployment that has not been given prices cannot sell at one. + delete process.env.DEDICATED_PRICE_ID_SHARED_CPU_1X_512; + mockFindForApp.mockResolvedValue(null); + expect(await buy()).toEqual({ ok: false, reason: 'price_not_configured' }); + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); + + it('refuses a price below the substrate floor', async () => { + // This guard is the only thing that makes MACHINE_MARKUP_BPS bind on a flat + // SKU, whose price is typed into a dashboard by a person. + mockFindForApp.mockResolvedValue(null); + mockPricesRetrieve.mockResolvedValue({ + active: true, + currency: 'usd', + unit_amount: 100, + recurring: { interval: 'month', interval_count: 1 }, + }); + expect(await buy()).toEqual({ ok: false, reason: 'price_below_floor' }); + expect(mockSubscriptionsCreate).not.toHaveBeenCalled(); + }); + + it('refuses a price that is not monthly USD', async () => { + mockFindForApp.mockResolvedValue(null); + mockPricesRetrieve.mockResolvedValue({ + active: true, + currency: 'usd', + unit_amount: 500_000, + recurring: { interval: 'year', interval_count: 1 }, + }); + expect(await buy()).toEqual({ ok: false, reason: 'price_not_monthly_usd' }); + }); +}); + +describe('a successful purchase', () => { + it('stamps the discriminator that keeps it out of the account-tier machinery', async () => { + mockFindForApp.mockResolvedValue(null); + await buy(); + const args = mockSubscriptionsCreate.mock.calls[0][0]; + expect(args.metadata.kind).toBe('published_app_dedicated'); + expect(args.metadata.publishedAppId).toBe('app_1'); + }); + + it('does NOT make the app dedicated — entitlement waits for Stripe', async () => { + // The subscription is created `incomplete`, i.e. unpaid. Flipping the tier here + // would hand an always-on machine to anyone who starts a checkout and abandons + // it. + mockFindForApp.mockResolvedValue(null); + await buy(); + expect(mockRecord).toHaveBeenCalledWith(expect.objectContaining({ status: 'incomplete' })); + }); +}); + +describe('two overlapping purchases for the same app', () => { + it('sends Stripe an idempotency key, so the race cannot mint two subscriptions', async () => { + // The live-subscription read cannot serialize anything: two POSTs read before + // either writes, both pass, and both call `subscriptions.create`. Stripe would + // mint TWO recurring charges and the mirror's UNIQUE keeps one pointer — + // leaving the other billing the customer monthly with nothing naming it. + mockFindForApp.mockResolvedValue(null); + await buy(); + const [, options] = mockSubscriptionsCreate.mock.calls[0]; + expect(options?.idempotencyKey).toBe('pgs-dedicated:app_1:none'); + }); + + it('gives two racing requests the SAME key', async () => { + // They agree on what they read, so they agree on the key, so Stripe answers + // both with one subscription. + mockFindForApp.mockResolvedValue(null); + await Promise.all([buy(), buy()]); + const keys = mockSubscriptionsCreate.mock.calls.map((c) => c[1]?.idempotencyKey); + expect(new Set(keys).size, 'racing requests must not compute different keys').toBe(1); + }); + + it('gives a genuine RE-BUY a different key', async () => { + // Otherwise Stripe would answer a real new purchase with the dead + // subscription it already created under that key. + mockFindForApp.mockResolvedValue(mirror('canceled')); + await buy(); + const [, options] = mockSubscriptionsCreate.mock.calls[0]; + expect(options?.idempotencyKey).toBe('pgs-dedicated:app_1:sub_old'); + }); +}); diff --git a/apps/web/src/lib/app-hosting/dedicated-price.ts b/apps/web/src/lib/app-hosting/dedicated-price.ts new file mode 100644 index 0000000000..522745a16f --- /dev/null +++ b/apps/web/src/lib/app-hosting/dedicated-price.ts @@ -0,0 +1,132 @@ +/** + * dedicated-price — which Stripe price sells which guest size, and the floor + * below which we refuse to sell it. + * + * WHY THESE ARE ENV VARS AND NOT `stripe-config.ts`. That file hardcodes the + * account-plan price ids for a specific reason stated in its own header: they are + * `NEXT_PUBLIC` values that Next.js has to inline at build time, and they end up + * in the client bundle anyway. The dedicated prices are the opposite on both + * counts — the subscription is created server-side and no price id is ever sent to + * a browser — and there is one per guest size rather than one per plan. Reading + * them from the server environment means the SKU can be switched on by creating + * prices in Stripe and setting env, with no code change and no rebuild, which is + * exactly what "ships dark" needs. + * + * FAIL CLOSED, in the same shape as `resolveFlyMachinesToken()`: an unconfigured + * preset yields null and the caller refuses the purchase BEFORE any Stripe call. + * A deployment that has not been given prices cannot accidentally sell at one. + */ + +import { stripe } from '@/lib/stripe'; +import { + dedicatedMonthlyFloorCents, + findGuestPreset, +} from '@pagespace/lib/services/app-hosting/dedicated-tier'; + +/** + * The env var carrying a preset's Stripe price id. + * + * Derived from the preset name rather than listed, so adding a size to the + * catalogue cannot leave a price lookup silently pointing at nothing: the + * variable's name follows the preset's, and a missing one is reported by name in + * the refusal. + */ +export function dedicatedPriceEnvVar(guestPreset: string): string { + return `DEDICATED_PRICE_ID_${guestPreset.toUpperCase().replace(/-/g, '_')}`; +} + +/** The configured Stripe price id for a preset, or null when unset/blank. */ +export function resolveDedicatedPriceId(guestPreset: string): string | null { + const configured = process.env[dedicatedPriceEnvVar(guestPreset)]; + return configured && configured.trim().length > 0 ? configured.trim() : null; +} + +export type DedicatedPriceRefusal = + /** The preset is not in the catalogue at all. */ + | 'unknown_preset' + /** No price id is configured for this preset on this deployment. */ + | 'price_not_configured' + /** Stripe does not recognise the configured id, or would not return it. */ + | 'price_not_found' + /** The price is inactive, one-off, not monthly, or not in USD. */ + | 'price_not_monthly_usd' + /** The price is below 1.5x the substrate cost of the guest it sells. */ + | 'price_below_floor'; + +export type DedicatedPriceCheck = + | { ok: true; priceId: string; unitAmountCents: number; floorCents: number } + | { ok: false; reason: DedicatedPriceRefusal; floorCents: number; unitAmountCents?: number }; + +/** + * Resolve and VALIDATE the price that sells this guest size. + * + * The validation is the point, and it is what makes `MACHINE_MARKUP_BPS` bind on a + * flat SKU at all. A metered app's markup is enforced every time `consumeCredits` + * settles; a dedicated app's price is typed into a Stripe dashboard by a person, + * and nothing else in the system would ever look at it again. So it is checked + * once, at the moment of sale, against the same constants the metered tier is + * priced from: + * + * - MONTHLY and RECURRING, because the floor is a per-month figure. A one-off + * price or an annual one compared against a monthly floor is not a comparison, + * it is a category error that happens to typecheck. + * - USD, for the same reason: the floor is denominated in US cents, and + * comparing it to an amount in another currency silently sells at whatever the + * exchange rate happens to be. + * - ACTIVE, because an archived price still resolves and still cannot be + * subscribed to — better a named refusal than a Stripe error at create time. + * - AT OR ABOVE the floor. See `calculateDedicatedMonthlyFloorCents` for what + * the floor is and its important caveat: it is derived from the Sprites rate + * table, which is generous, so a refusal here is more likely to mean "these + * constants do not suit a hosting product" than "somebody mispriced it". It + * refuses either way — a floor that warned would not be a floor. + * + * `tiers` is not consulted here: whether a preset may be run at all on a given + * tier is `isGuestPresetAllowedForTier`'s question, asked by the caller before it + * gets this far. This function answers only "what does it cost and may we sell it + * at that". + */ +export async function checkDedicatedPrice(guestPreset: string): Promise { + const preset = findGuestPreset(guestPreset); + if (!preset) return { ok: false, reason: 'unknown_preset', floorCents: 0 }; + + const floorCents = dedicatedMonthlyFloorCents(guestPreset); + const priceId = resolveDedicatedPriceId(guestPreset); + if (!priceId) return { ok: false, reason: 'price_not_configured', floorCents }; + + let price; + try { + price = await stripe.prices.retrieve(priceId); + } catch { + // The id is configured but Stripe will not return it — a typo, a price from + // another account, or test/live mode crossed. Named rather than rethrown: the + // caller's answer is the same as for an unconfigured price (refuse, change no + // state), and an exception here would surface as a 500 on a misconfiguration. + return { ok: false, reason: 'price_not_found', floorCents }; + } + + const recurring = price.recurring; + const monthly = + price.active && + price.currency === 'usd' && + recurring != null && + recurring.interval === 'month' && + recurring.interval_count === 1; + if (!monthly) return { ok: false, reason: 'price_not_monthly_usd', floorCents }; + + // A price with no `unit_amount` is tiered or metered — Stripe leaves the field + // null and puts the money in `tiers`. There is no single figure to compare + // against a floor, so it cannot clear one. Folded into the same refusal as a + // genuinely-too-cheap price because the caller's action is identical. + const unitAmountCents = price.unit_amount; + if (unitAmountCents == null || unitAmountCents < floorCents) { + return { + ok: false, + reason: 'price_below_floor', + floorCents, + ...(unitAmountCents == null ? {} : { unitAmountCents }), + }; + } + + return { ok: true, priceId, unitAmountCents, floorCents }; +} diff --git a/apps/web/src/lib/app-hosting/dedicated-subscription.ts b/apps/web/src/lib/app-hosting/dedicated-subscription.ts new file mode 100644 index 0000000000..bc43607fcb --- /dev/null +++ b/apps/web/src/lib/app-hosting/dedicated-subscription.ts @@ -0,0 +1,249 @@ +/** + * dedicated-subscription — buying and cancelling the flat monthly SKU that makes + * a published app always-on. + * + * Follows the account-plan pattern in `api/stripe/create-subscription/route.ts`: + * get-or-create the customer, create the subscription with + * `payment_behavior: 'default_incomplete'` and a `confirmation_secret`, hand the + * client secret back for the PaymentElement to confirm. The differences from that + * route are the two that matter and both are deliberate: + * + * 1. THE SUBSCRIPTION IS STAMPED `metadata.kind = 'published_app_dedicated'`, + * which is the only thing that keeps it out of the account-tier machinery. + * Without it the webhook's `handleSubscriptionChange` would derive an account + * tier of `free` from this unmapped price and write it over a paying + * customer's tier — see the docblock on `published_app_subscriptions`. The + * stamp is applied at CREATE and Stripe treats it as immutable enough for our + * purposes (it is snapshotted onto every invoice at finalization), which is + * what lets invoice events be routed too. + * 2. THE APP IS NOT MADE DEDICATED HERE. The subscription is created + * `incomplete`, i.e. unpaid; entitlement begins when Stripe says `active`, + * which arrives as a webhook. Flipping the tier at create time would hand out + * an always-on machine to anyone who started a checkout and abandoned it. + * The mirror row IS written now, so the webhook that follows has something to + * resolve the subscription to. + * + * Every entry point refuses BEFORE touching Stripe when `isDedicatedTierPurchasable()` + * is false — hosting dark, or a deployment (tenant, onprem) where + * `isBillingEnabled()` is false and there is no customer to charge. + */ + +import { stripe } from '@/lib/stripe'; +import { getOrCreateStripeCustomer } from '@/lib/stripe-customer'; +import { loggers } from '@pagespace/lib/logging/logger-config'; +import { + DEDICATED_SUBSCRIPTION_KIND, + SUBSCRIPTION_KIND_METADATA_KEY, + isDedicatedEntitled, + isGuestPresetAllowedForTier, +} from '@pagespace/lib/services/app-hosting/dedicated-tier'; +import { + findDedicatedSubscriptionForApp, + isDedicatedTierPurchasable, + recordDedicatedSubscription, +} from '@pagespace/lib/services/app-hosting/dedicated-tier-service'; +import { checkDedicatedPrice, type DedicatedPriceRefusal } from './dedicated-price'; +import { subscriptionPeriod } from './stripe-subscription-period'; + +export type StartDedicatedRefusal = + | 'unavailable' + | 'guest_preset_not_allowed' + | 'already_subscribed' + | DedicatedPriceRefusal; + +export type StartDedicatedResult = + | { ok: true; stripeSubscriptionId: string; clientSecret: string; status: string } + | { ok: false; reason: StartDedicatedRefusal }; + +export interface StartDedicatedInput { + publishedAppId: string; + /** The app's payer — the drive owner, resolved by the caller. */ + user: Parameters[0]; + /** The size being bought. Must be dedicated-legal and must have a configured price. */ + guestPreset: string; +} + +/** + * Start a dedicated subscription for one published app. + * + * Returns a client secret for the PaymentElement; the app becomes dedicated only + * when Stripe reports the subscription `active` (or `trialing`) through the + * webhook. + */ +export async function startDedicatedSubscription( + input: StartDedicatedInput, +): Promise { + if (!isDedicatedTierPurchasable()) return { ok: false, reason: 'unavailable' }; + + // Asked before the price, and before Stripe: a size the dedicated tier may not + // run is not a pricing question, and a deployment that has configured a price + // for it anyway must still be refused. + if (!isGuestPresetAllowedForTier(input.guestPreset, 'dedicated')) { + return { ok: false, reason: 'guest_preset_not_allowed' }; + } + + // One LIVE dedicated subscription per app — and "live" is the whole point of + // this check, not "a row exists". + // + // The mirror keeps a row after a subscription ends, deliberately: the row is + // what explains WHY an app went back to metered. So refusing on mere existence + // would mean one abandoned checkout (`incomplete_expired`) or one ordinary + // cancellation permanently brick the SKU for that app — the customer could + // never buy always-on again, and the cancel escape hatch would not help them + // either, since cancelling an already-terminal Stripe subscription errors. + // + // What must be refused is a SECOND concurrent charge: a subscription that is + // paying (`active` / `trialing` / `past_due`), or one whose checkout is still + // open (`incomplete` — the customer has a payment sheet in front of them right + // now, and issuing a second subscription would let them pay twice for one app). + // Anything terminal is a closed chapter, and re-buying overwrites the mirror row + // through the upsert's `publishedAppId` conflict target. + const existing = await findDedicatedSubscriptionForApp(input.publishedAppId); + if (existing && (isDedicatedEntitled(existing.status) || existing.status === 'incomplete')) { + return { ok: false, reason: 'already_subscribed' }; + } + + const price = await checkDedicatedPrice(input.guestPreset); + if (!price.ok) { + loggers.api.warn('Dedicated hosting purchase refused on price validation', { + publishedAppId: input.publishedAppId, + guestPreset: input.guestPreset, + reason: price.reason, + floorCents: price.floorCents, + unitAmountCents: price.unitAmountCents, + }); + return { ok: false, reason: price.reason }; + } + + const customerId = await getOrCreateStripeCustomer(input.user); + + // AN IDEMPOTENCY KEY, because the read above cannot serialize anything. + // + // Two POSTs for the same app can both pass the live-subscription check — they + // read before either writes — and then both call `subscriptions.create`. Stripe + // would mint TWO recurring subscriptions; the mirror's UNIQUE on + // `publishedAppId` keeps only one pointer, so the other charges the customer + // every month with nothing in our database naming it. A preflight read cannot + // close that: the window is between the read and Stripe, and no amount of + // checking first removes it. + // + // The key is derived from what the racing requests AGREE on — the app and the + // subscription they each saw as the previous one — so two overlapping purchases + // compute the same key and Stripe returns one subscription to both. It is not + // constant per app, which matters in the other direction: a legitimate re-buy + // after a cancellation follows a DIFFERENT previous id, so it gets its own key + // and is not silently answered with the dead subscription. (Stripe expires keys + // after 24 hours, so an app whose first purchase never existed re-uses + // `none` — correct, because that is genuinely the same logical request until one + // of them succeeds.) + const idempotencyKey = `pgs-dedicated:${input.publishedAppId}:${existing?.stripeSubscriptionId ?? 'none'}`; + + const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: price.priceId }], + payment_behavior: 'default_incomplete', + payment_settings: { save_default_payment_method: 'on_subscription' }, + expand: ['latest_invoice.confirmation_secret'], + metadata: { + // THE DISCRIMINATOR. Everything downstream — the subscription webhook, the + // invoice webhook, the credit-refill path — routes on this one key. A + // subscription created without it is an account plan as far as the rest of + // the system is concerned. + [SUBSCRIPTION_KIND_METADATA_KEY]: DEDICATED_SUBSCRIPTION_KIND, + publishedAppId: input.publishedAppId, + guestPreset: input.guestPreset, + userId: input.user.id, + }, + }, { idempotencyKey }); + + const period = subscriptionPeriod(subscription); + // Written BEFORE the customer pays, and that ordering is the same one + // `published_apps` uses for its Fly app: the local pointer exists before the + // billable thing does. A crash between the Stripe create and this write would + // leave a subscription in Stripe that nothing local can resolve — the webhook's + // `unknown_subscription` outcome — whereas this ordering leaves at worst a + // mirror row for a subscription that never activates, which the status column + // describes accurately and which entitles nobody. + await recordDedicatedSubscription({ + publishedAppId: input.publishedAppId, + userId: input.user.id, + stripeSubscriptionId: subscription.id, + stripePriceId: price.priceId, + guestPreset: input.guestPreset, + status: subscription.status, + currentPeriodStart: period.start, + currentPeriodEnd: period.end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + // No stamp: this write comes from an API RESPONSE, not a webhook, so there is + // no `event.created` to order it by. A null stamp reads as "unknown order" and + // does not block the first real event from landing — the terminal-status rule + // is what protects this row until an event stamps it. + stripeEventCreated: null, + }); + + const invoice = subscription.latest_invoice as + | { confirmation_secret?: { client_secret?: string } } + | null; + const clientSecret = invoice?.confirmation_secret?.client_secret; + if (!clientSecret) { + // The subscription exists and the mirror row records it, so this is not a + // rollback situation — it is a create we cannot hand a payment sheet for. The + // customer retries; `already_subscribed` then points them at the pending + // subscription rather than creating a second one. + loggers.api.error('Dedicated hosting subscription created with no confirmation secret', undefined, { + publishedAppId: input.publishedAppId, + stripeSubscriptionId: subscription.id, + }); + return { ok: false, reason: 'price_not_found' }; + } + + return { + ok: true, + stripeSubscriptionId: subscription.id, + clientSecret, + status: subscription.status, + }; +} + +export type CancelDedicatedResult = + | { ok: true; cancelAtPeriodEnd: boolean; currentPeriodEnd: Date } + | { ok: false; reason: 'unavailable' | 'not_subscribed' }; + +/** + * Cancel an app's dedicated subscription AT PERIOD END. + * + * At period end rather than immediately, because the customer has paid for the + * month: an immediate cancel would take an always-on app back to scale-to-zero + * partway through a period they are already billed for. The tier follows when + * Stripe emits `customer.subscription.deleted` at the end of the period — the + * webhook is the only thing that moves the tier, here as everywhere, so a cancel + * that is later reversed (`reactivate`) needs no compensating local write. + */ +export async function cancelDedicatedSubscription( + publishedAppId: string, +): Promise { + if (!isDedicatedTierPurchasable()) return { ok: false, reason: 'unavailable' }; + + const mirror = await findDedicatedSubscriptionForApp(publishedAppId); + if (!mirror) return { ok: false, reason: 'not_subscribed' }; + + const subscription = await stripe.subscriptions.update(mirror.stripeSubscriptionId, { + cancel_at_period_end: true, + }); + const period = subscriptionPeriod(subscription); + + await recordDedicatedSubscription({ + publishedAppId: mirror.publishedAppId, + userId: mirror.userId, + stripeSubscriptionId: subscription.id, + stripePriceId: mirror.stripePriceId, + guestPreset: mirror.guestPreset, + status: subscription.status, + currentPeriodStart: period.start, + currentPeriodEnd: period.end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + stripeEventCreated: null, + }); + + return { ok: true, cancelAtPeriodEnd: subscription.cancel_at_period_end, currentPeriodEnd: period.end }; +} diff --git a/apps/web/src/lib/app-hosting/stripe-subscription-period.ts b/apps/web/src/lib/app-hosting/stripe-subscription-period.ts new file mode 100644 index 0000000000..f41804280a --- /dev/null +++ b/apps/web/src/lib/app-hosting/stripe-subscription-period.ts @@ -0,0 +1,64 @@ +/** + * subscriptionPeriod — read a Stripe subscription's current billing period. + * + * Exists because the period moved. As of Stripe API version 2025-08-27 + * `current_period_start` / `current_period_end` live on the subscription ITEM, + * not on the subscription, and the SDK's `Subscription` type has not been widened + * to say so — which is why `handleSubscriptionChange` in the Stripe webhook casts + * an item to an intersection type to reach them. This is that extraction, in one + * place, for the hosting path that now needs it too. + */ + +import type { Stripe } from '@/lib/stripe'; +import { loggers } from '@pagespace/lib/logging/logger-config'; + +/** Subscription-item fields the SDK's type does not yet carry. */ +type ItemWithPeriod = Stripe.SubscriptionItem & { + current_period_start?: number; + current_period_end?: number; +}; + +export interface SubscriptionPeriod { + start: Date; + end: Date; +} + +/** + * WHAT HAPPENS WHEN STRIPE GIVES NO USABLE PERIOD, and why it is a placeholder + * rather than a thrown error. + * + * The alternative — refusing to write — is the worse trade at the one moment this + * matters. The mirror row is written immediately after the Stripe subscription is + * created, so failing over an unreadable date leaves a live recurring charge in + * Stripe that nothing locally can resolve: a subscription we cannot cancel, + * attribute or reason about. A zero-length window at `now` is self-healing + * instead — nothing gates on these two columns (entitlement is the `status` + * column), and the next subscription webhook overwrites them with the real period. + * + * The warning below is how it is said out loud. It is deliberately not also a flag + * on the return value: no caller has anything different to do about it, and a + * field nobody reads is a claim that somebody is checking. + */ + +export function subscriptionPeriod(subscription: Stripe.Subscription): SubscriptionPeriod { + const item = subscription.items?.data?.[0] as ItemWithPeriod | undefined; + const startTs = item?.current_period_start; + const endTs = item?.current_period_end; + + if (typeof startTs === 'number' && typeof endTs === 'number' && startTs > 0 && endTs >= startTs) { + const start = new Date(startTs * 1000); + const end = new Date(endTs * 1000); + if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + return { start, end }; + } + } + + loggers.api.warn('Stripe subscription carried no readable billing period; mirroring a placeholder', { + subscriptionId: subscription.id, + itemId: item?.id, + startTs, + endTs, + }); + const now = new Date(); + return { start: now, end: now }; +} diff --git a/knip.json b/knip.json index f1e9cbcd00..c34b4b0ece 100644 --- a/knip.json +++ b/knip.json @@ -146,6 +146,7 @@ "src/schema/pending-invites.ts", "src/schema/pending-page-invites.ts", "src/schema/personalization.ts", + "src/schema/published-app-subscriptions.ts", "src/schema/published-apps.ts", "src/schema/published-pages.ts", "src/schema/push-notifications.ts", @@ -421,6 +422,8 @@ "src/services/agent-workspaces/workspace-status.ts", "src/services/agent-workspaces/shell-types.ts", "src/services/app-hosting/app-hosting-env.ts", + "src/services/app-hosting/dedicated-tier.ts", + "src/services/app-hosting/dedicated-tier-service.ts", "src/services/app-hosting/app-replay-key.ts", "src/services/app-hosting/parked-page.ts", "src/services/app-hosting/provisioner-core.ts", diff --git a/packages/db/drizzle/0276_cloudy_madame_hydra.sql b/packages/db/drizzle/0276_cloudy_madame_hydra.sql new file mode 100644 index 0000000000..392894156c --- /dev/null +++ b/packages/db/drizzle/0276_cloudy_madame_hydra.sql @@ -0,0 +1,27 @@ +CREATE TABLE "published_app_subscriptions" ( + "id" text PRIMARY KEY NOT NULL, + "publishedAppId" text NOT NULL, + "userId" text NOT NULL, + "stripeSubscriptionId" text NOT NULL, + "stripePriceId" text NOT NULL, + "guestPreset" text NOT NULL, + "status" text NOT NULL, + "stripeEventCreated" timestamp with time zone, + "currentPeriodStart" timestamp with time zone NOT NULL, + "currentPeriodEnd" timestamp with time zone NOT NULL, + "cancelAtPeriodEnd" boolean DEFAULT false NOT NULL, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "published_app_subscriptions_publishedAppId_unique" UNIQUE("publishedAppId"), + CONSTRAINT "published_app_subscriptions_stripeSubscriptionId_unique" UNIQUE("stripeSubscriptionId"), + CONSTRAINT "published_app_subscriptions_status_nonempty" CHECK (length("published_app_subscriptions"."status") > 0), + CONSTRAINT "published_app_subscriptions_period_ordered" CHECK ("published_app_subscriptions"."currentPeriodEnd" >= "published_app_subscriptions"."currentPeriodStart") +); +--> statement-breakpoint +ALTER TABLE "published_apps" DROP CONSTRAINT "published_apps_guest_preset_allowed";--> statement-breakpoint +ALTER TABLE "published_app_subscriptions" ADD CONSTRAINT "published_app_subscriptions_publishedAppId_published_apps_id_fk" FOREIGN KEY ("publishedAppId") REFERENCES "public"."published_apps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "published_app_subscriptions" ADD CONSTRAINT "published_app_subscriptions_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "published_app_subscriptions_user_idx" ON "published_app_subscriptions" USING btree ("userId");--> statement-breakpoint +CREATE INDEX "published_app_subscriptions_stripe_subscription_idx" ON "published_app_subscriptions" USING btree ("stripeSubscriptionId");--> statement-breakpoint +ALTER TABLE "published_apps" ADD CONSTRAINT "published_apps_metered_guest_preset" CHECK ("published_apps"."tier" <> 'metered' OR "published_apps"."guestPreset" = 'shared-cpu-1x-512');--> statement-breakpoint +ALTER TABLE "published_apps" ADD CONSTRAINT "published_apps_guest_preset_allowed" CHECK ("published_apps"."guestPreset" IN ('shared-cpu-1x-512', 'shared-cpu-1x-1024', 'shared-cpu-2x-2048', 'shared-cpu-4x-4096')); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0276_snapshot.json b/packages/db/drizzle/meta/0276_snapshot.json new file mode 100644 index 0000000000..c31730343a --- /dev/null +++ b/packages/db/drizzle/meta/0276_snapshot.json @@ -0,0 +1,23459 @@ +{ + "id": "22c05835-727a-44ad-9413-9af138581db9", + "prevId": "6bea6cbe-e384-40cc-b3bc-d7a387ceebfe", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.device_tokens": { + "name": "device_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "PlatformType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deviceName": { + "name": "deviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastIpAddress": { + "name": "lastIpAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trustScore": { + "name": "trustScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "suspiciousActivityCount": { + "name": "suspiciousActivityCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replacedByTokenId": { + "name": "replacedByTokenId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_tokens_user_id_idx": { + "name": "device_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_token_hash_idx": { + "name": "device_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_device_id_idx": { + "name": "device_tokens_device_id_idx", + "columns": [ + { + "expression": "deviceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_expires_at_idx": { + "name": "device_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_tokens_active_device_idx": { + "name": "device_tokens_active_device_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deviceId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_tokens\".\"revokedAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_tokens_userId_users_id_fk": { + "name": "device_tokens_userId_users_id_fk", + "tableFrom": "device_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "device_tokens_tokenHash_unique": { + "name": "device_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unsubscribe_tokens": { + "name": "email_unsubscribe_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unsubscribe_tokens_token_hash_idx": { + "name": "email_unsubscribe_tokens_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_unsubscribe_tokens_user_id_idx": { + "name": "email_unsubscribe_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_unsubscribe_tokens_expires_at_idx": { + "name": "email_unsubscribe_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_unsubscribe_tokens_user_id_users_id_fk": { + "name": "email_unsubscribe_tokens_user_id_users_id_fk", + "tableFrom": "email_unsubscribe_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_unsubscribe_tokens_token_hash_unique": { + "name": "email_unsubscribe_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tokens": { + "name": "mcp_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isScoped": { + "name": "isScoped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastUsed": { + "name": "lastUsed", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcp_tokens_user_id_idx": { + "name": "mcp_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_tokens_token_hash_idx": { + "name": "mcp_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_tokens_userId_users_id_fk": { + "name": "mcp_tokens_userId_users_id_fk", + "tableFrom": "mcp_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_tokens_tokenHash_unique": { + "name": "mcp_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transports": { + "name": "transports", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkeys_credential_id_idx": { + "name": "passkeys_credential_id_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkeys_credential_id_unique": { + "name": "passkeys_credential_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credential_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.socket_tokens": { + "name": "socket_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socket_tokens_user_id_idx": { + "name": "socket_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socket_tokens_token_hash_idx": { + "name": "socket_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socket_tokens_expires_at_idx": { + "name": "socket_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "socket_tokens_userId_users_id_fk": { + "name": "socket_tokens_userId_users_id_fk", + "tableFrom": "socket_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "socket_tokens_tokenHash_unique": { + "name": "socket_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailBidx": { + "name": "emailBidx", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appleId": { + "name": "appleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "AuthProvider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "UserRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "adminRoleVersion": { + "name": "adminRoleVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currentAiProvider": { + "name": "currentAiProvider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai'" + }, + "currentAiModel": { + "name": "currentAiModel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai/gpt-5.6-luna'" + }, + "imageGenerationModel": { + "name": "imageGenerationModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storageUsedBytes": { + "name": "storageUsedBytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "activeUploads": { + "name": "activeUploads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastStorageCalculated": { + "name": "lastStorageCalculated", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptionTier": { + "name": "subscriptionTier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "tosAcceptedAt": { + "name": "tosAcceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedLoginAttempts": { + "name": "failedLoginAttempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lockedUntil": { + "name": "lockedUntil", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspendedAt": { + "name": "suspendedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspendedReason": { + "name": "suspendedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "starterSkillsInstalledAt": { + "name": "starterSkillsInstalledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_bidx_idx": { + "name": "users_email_bidx_idx", + "columns": [ + { + "expression": "emailBidx", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_googleId_unique": { + "name": "users_googleId_unique", + "nullsNotDistinct": false, + "columns": [ + "googleId" + ] + }, + "users_appleId_unique": { + "name": "users_appleId_unique", + "nullsNotDistinct": false, + "columns": [ + "appleId" + ] + }, + "users_stripeCustomerId_unique": { + "name": "users_stripeCustomerId_unique", + "nullsNotDistinct": false, + "columns": [ + "stripeCustomerId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "usedAt": { + "name": "usedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_tokens_user_id_idx": { + "name": "verification_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_tokens_token_hash_idx": { + "name": "verification_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_tokens_type_idx": { + "name": "verification_tokens_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_tokens_userId_users_id_fk": { + "name": "verification_tokens_userId_users_id_fk", + "tableFrom": "verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_tokens_tokenHash_unique": { + "name": "verification_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "admin_role_version": { + "name": "admin_role_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_service": { + "name": "created_by_service", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_ip": { + "name": "created_by_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_ip": { + "name": "last_used_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_active_idx": { + "name": "sessions_user_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_device_idx": { + "name": "sessions_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drives": { + "name": "drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "DriveKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STANDARD'" + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "drivePrompt": { + "name": "drivePrompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publishSubdomain": { + "name": "publishSubdomain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "homePageId": { + "name": "homePageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_default_og_image_url": { + "name": "publish_default_og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "not_found_page_id": { + "name": "not_found_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_favicon_url": { + "name": "publish_favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drives_owner_id_idx": { + "name": "drives_owner_id_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drives_owner_id_slug_key": { + "name": "drives_owner_id_slug_key", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drives_owner_home_unique": { + "name": "drives_owner_home_unique", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"drives\".\"kind\" = 'HOME'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drives_ownerId_users_id_fk": { + "name": "drives_ownerId_users_id_fk", + "tableFrom": "drives", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drives_homePageId_pages_id_fk": { + "name": "drives_homePageId_pages_id_fk", + "tableFrom": "drives", + "tableTo": "pages", + "columnsFrom": [ + "homePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drives_not_found_page_id_pages_id_fk": { + "name": "drives_not_found_page_id_pages_id_fk", + "tableFrom": "drives", + "tableTo": "pages", + "columnsFrom": [ + "not_found_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drives_publishSubdomain_unique": { + "name": "drives_publishSubdomain_unique", + "nullsNotDistinct": false, + "columns": [ + "publishSubdomain" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "itemType": { + "name": "itemType", + "type": "FavoriteItemType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'page'" + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_id_page_id_key": { + "name": "favorites_user_id_page_id_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "favorites_user_id_drive_id_key": { + "name": "favorites_user_id_drive_id_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "favorites_user_id_position_idx": { + "name": "favorites_user_id_position_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_userId_users_id_fk": { + "name": "favorites_userId_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_pageId_pages_id_fk": { + "name": "favorites_pageId_pages_id_fk", + "tableFrom": "favorites", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_driveId_drives_id_fk": { + "name": "favorites_driveId_drives_id_fk", + "tableFrom": "favorites", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "favorites_item_type_consistency_chk": { + "name": "favorites_item_type_consistency_chk", + "value": "((\"itemType\" = 'page' AND \"pageId\" IS NOT NULL AND \"driveId\" IS NULL) OR (\"itemType\" = 'drive' AND \"driveId\" IS NOT NULL AND \"pageId\" IS NULL))" + } + }, + "isRLSEnabled": false + }, + "public.mentions": { + "name": "mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sourcePageId": { + "name": "sourcePageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetPageId": { + "name": "targetPageId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mentions_source_page_id_target_page_id_key": { + "name": "mentions_source_page_id_target_page_id_key", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "targetPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mentions_source_page_id_idx": { + "name": "mentions_source_page_id_idx", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mentions_target_page_id_idx": { + "name": "mentions_target_page_id_idx", + "columns": [ + { + "expression": "targetPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mentions_sourcePageId_pages_id_fk": { + "name": "mentions_sourcePageId_pages_id_fk", + "tableFrom": "mentions", + "tableTo": "pages", + "columnsFrom": [ + "sourcePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mentions_targetPageId_pages_id_fk": { + "name": "mentions_targetPageId_pages_id_fk", + "tableFrom": "mentions", + "tableTo": "pages", + "columnsFrom": [ + "targetPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "PageType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "contentMode": { + "name": "contentMode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'html'" + }, + "isPaginated": { + "name": "isPaginated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "systemPrompt": { + "name": "systemPrompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabledTools": { + "name": "enabledTools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "includeDrivePrompt": { + "name": "includeDrivePrompt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agentDefinition": { + "name": "agentDefinition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibleToGlobalAssistant": { + "name": "visibleToGlobalAssistant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includePageTree": { + "name": "includePageTree", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pageTreeScope": { + "name": "pageTreeScope", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'children'" + }, + "toolExposureMode": { + "name": "toolExposureMode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'upfront'" + }, + "sandboxEnabled": { + "name": "sandboxEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userScopedAccess": { + "name": "userScopedAccess", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "siteMode": { + "name": "siteMode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fileSize": { + "name": "fileSize", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalFileName": { + "name": "originalFileName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filePath": { + "name": "filePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fileMetadata": { + "name": "fileMetadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processingStatus": { + "name": "processingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "processingError": { + "name": "processingError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "extractionMethod": { + "name": "extractionMethod", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extractionMetadata": { + "name": "extractionMetadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contentHash": { + "name": "contentHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excludeFromSearch": { + "name": "excludeFromSearch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isPrivate": { + "name": "isPrivate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stateHash": { + "name": "stateHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalParentId": { + "name": "originalParentId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pages_drive_id_idx": { + "name": "pages_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_idx": { + "name": "pages_parent_id_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_position_idx": { + "name": "pages_parent_id_position_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_drive_id_is_trashed_type_idx": { + "name": "pages_drive_id_is_trashed_type_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isTrashed", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_createdBy_users_id_fk": { + "name": "pages_createdBy_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pages_driveId_drives_id_fk": { + "name": "pages_driveId_drives_id_fk", + "tableFrom": "pages", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_events": { + "name": "storage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sizeDelta": { + "name": "sizeDelta", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "totalSizeAfter": { + "name": "totalSizeAfter", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "storage_events_user_id_idx": { + "name": "storage_events_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_events_created_at_idx": { + "name": "storage_events_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_events_userId_users_id_fk": { + "name": "storage_events_userId_users_id_fk", + "tableFrom": "storage_events", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "storage_events_pageId_pages_id_fk": { + "name": "storage_events_pageId_pages_id_fk", + "tableFrom": "storage_events", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalizedKey": { + "name": "normalizedKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tags_drive_id_idx": { + "name": "tags_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_driveId_drives_id_fk": { + "name": "tags_driveId_drives_id_fk", + "tableFrom": "tags", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tags_createdBy_users_id_fk": { + "name": "tags_createdBy_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_drive_id_normalized_key_key": { + "name": "tags_drive_id_normalized_key_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "normalizedKey" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_mentions": { + "name": "user_mentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sourcePageId": { + "name": "sourcePageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetUserId": { + "name": "targetUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentionedByUserId": { + "name": "mentionedByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_mentions_source_page_id_target_user_id_key": { + "name": "user_mentions_source_page_id_target_user_id_key", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "targetUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_mentions_source_page_id_idx": { + "name": "user_mentions_source_page_id_idx", + "columns": [ + { + "expression": "sourcePageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_mentions_target_user_id_idx": { + "name": "user_mentions_target_user_id_idx", + "columns": [ + { + "expression": "targetUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_mentions_sourcePageId_pages_id_fk": { + "name": "user_mentions_sourcePageId_pages_id_fk", + "tableFrom": "user_mentions", + "tableTo": "pages", + "columnsFrom": [ + "sourcePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_mentions_targetUserId_users_id_fk": { + "name": "user_mentions_targetUserId_users_id_fk", + "tableFrom": "user_mentions", + "tableTo": "users", + "columnsFrom": [ + "targetUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_mentions_mentionedByUserId_users_id_fk": { + "name": "user_mentions_mentionedByUserId_users_id_fk", + "tableFrom": "user_mentions", + "tableTo": "users", + "columnsFrom": [ + "mentionedByUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_agent_members": { + "name": "drive_agent_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "includeContext": { + "name": "includeContext", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addedAt": { + "name": "addedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "drive_agent_members_drive_id_idx": { + "name": "drive_agent_members_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_agent_members_agent_page_id_idx": { + "name": "drive_agent_members_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_agent_members_driveId_drives_id_fk": { + "name": "drive_agent_members_driveId_drives_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_agent_members_agentPageId_pages_id_fk": { + "name": "drive_agent_members_agentPageId_pages_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_agent_members_customRoleId_drive_roles_id_fk": { + "name": "drive_agent_members_customRoleId_drive_roles_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_agent_members_addedBy_users_id_fk": { + "name": "drive_agent_members_addedBy_users_id_fk", + "tableFrom": "drive_agent_members", + "tableTo": "users", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_agent_members_drive_agent_key": { + "name": "drive_agent_members_drive_agent_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "agentPageId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_members": { + "name": "drive_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastAccessedAt": { + "name": "lastAccessedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_members_drive_id_idx": { + "name": "drive_members_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_user_id_idx": { + "name": "drive_members_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_role_idx": { + "name": "drive_members_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_members_custom_role_id_idx": { + "name": "drive_members_custom_role_id_idx", + "columns": [ + { + "expression": "customRoleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_members_driveId_drives_id_fk": { + "name": "drive_members_driveId_drives_id_fk", + "tableFrom": "drive_members", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_members_userId_users_id_fk": { + "name": "drive_members_userId_users_id_fk", + "tableFrom": "drive_members", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_members_customRoleId_drive_roles_id_fk": { + "name": "drive_members_customRoleId_drive_roles_id_fk", + "tableFrom": "drive_members", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_members_invitedBy_users_id_fk": { + "name": "drive_members_invitedBy_users_id_fk", + "tableFrom": "drive_members", + "tableTo": "users", + "columnsFrom": [ + "invitedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_members_drive_user_key": { + "name": "drive_members_drive_user_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_roles": { + "name": "drive_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "drive_wide_permissions": { + "name": "drive_wide_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drive_roles_drive_id_idx": { + "name": "drive_roles_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_roles_position_idx": { + "name": "drive_roles_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_roles_driveId_drives_id_fk": { + "name": "drive_roles_driveId_drives_id_fk", + "tableFrom": "drive_roles", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_roles_drive_name_key": { + "name": "drive_roles_drive_name_key", + "nullsNotDistinct": false, + "columns": [ + "driveId", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_token_drives": { + "name": "mcp_token_drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenId": { + "name": "tokenId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addedBy": { + "name": "addedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_token_drives_token_id_idx": { + "name": "mcp_token_drives_token_id_idx", + "columns": [ + { + "expression": "tokenId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_drives_drive_id_idx": { + "name": "mcp_token_drives_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_drives_token_drive_unique": { + "name": "mcp_token_drives_token_drive_unique", + "columns": [ + { + "expression": "tokenId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_token_drives_tokenId_mcp_tokens_id_fk": { + "name": "mcp_token_drives_tokenId_mcp_tokens_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "mcp_tokens", + "columnsFrom": [ + "tokenId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_token_drives_driveId_drives_id_fk": { + "name": "mcp_token_drives_driveId_drives_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_token_drives_customRoleId_drive_roles_id_fk": { + "name": "mcp_token_drives_customRoleId_drive_roles_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "drive_roles", + "columnsFrom": [ + "customRoleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_token_drives_addedBy_users_id_fk": { + "name": "mcp_token_drives_addedBy_users_id_fk", + "tableFrom": "mcp_token_drives", + "tableTo": "users", + "columnsFrom": [ + "addedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_permissions": { + "name": "page_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canView": { + "name": "canView", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canEdit": { + "name": "canEdit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canShare": { + "name": "canShare", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDelete": { + "name": "canDelete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grantedBy": { + "name": "grantedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grantedAt": { + "name": "grantedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_permissions_page_id_idx": { + "name": "page_permissions_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_permissions_user_id_idx": { + "name": "page_permissions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_permissions_expires_at_idx": { + "name": "page_permissions_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_permissions_pageId_pages_id_fk": { + "name": "page_permissions_pageId_pages_id_fk", + "tableFrom": "page_permissions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_permissions_userId_users_id_fk": { + "name": "page_permissions_userId_users_id_fk", + "tableFrom": "page_permissions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_permissions_grantedBy_users_id_fk": { + "name": "page_permissions_grantedBy_users_id_fk", + "tableFrom": "page_permissions", + "tableTo": "users", + "columnsFrom": [ + "grantedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_permissions_page_user_key": { + "name": "page_permissions_page_user_key", + "nullsNotDistinct": false, + "columns": [ + "pageId", + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "displayName": { + "name": "displayName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatarUrl": { + "name": "avatarUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_profiles_is_public_idx": { + "name": "user_profiles_is_public_idx", + "columns": [ + { + "expression": "isPublic", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_profiles_userId_users_id_fk": { + "name": "user_profiles_userId_users_id_fk", + "tableFrom": "user_profiles", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_message_reactions": { + "name": "channel_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_reaction_idx": { + "name": "unique_reaction_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_message_idx": { + "name": "reaction_message_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_message_reactions_messageId_channel_messages_id_fk": { + "name": "channel_message_reactions_messageId_channel_messages_id_fk", + "tableFrom": "channel_message_reactions", + "tableTo": "channel_messages", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_message_reactions_userId_users_id_fk": { + "name": "channel_message_reactions_userId_users_id_fk", + "tableFrom": "channel_message_reactions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_messages": { + "name": "channel_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachmentMeta": { + "name": "attachmentMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aiMeta": { + "name": "aiMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replyCount": { + "name": "replyCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastReplyAt": { + "name": "lastReplyAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mirroredFromId": { + "name": "mirroredFromId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotedMessageId": { + "name": "quotedMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "channel_messages_page_id_idx": { + "name": "channel_messages_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_file_id_idx": { + "name": "channel_messages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_parent_created_idx": { + "name": "channel_messages_parent_created_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_messages_quoted_id_idx": { + "name": "channel_messages_quoted_id_idx", + "columns": [ + { + "expression": "quotedMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_messages_pageId_pages_id_fk": { + "name": "channel_messages_pageId_pages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_userId_users_id_fk": { + "name": "channel_messages_userId_users_id_fk", + "tableFrom": "channel_messages", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_fileId_files_id_fk": { + "name": "channel_messages_fileId_files_id_fk", + "tableFrom": "channel_messages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channel_messages_parentId_channel_messages_id_fk": { + "name": "channel_messages_parentId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_messages_mirroredFromId_channel_messages_id_fk": { + "name": "channel_messages_mirroredFromId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "mirroredFromId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channel_messages_quotedMessageId_channel_messages_id_fk": { + "name": "channel_messages_quotedMessageId_channel_messages_id_fk", + "tableFrom": "channel_messages", + "tableTo": "channel_messages", + "columnsFrom": [ + "quotedMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_read_status": { + "name": "channel_read_status", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channelId": { + "name": "channelId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastReadAt": { + "name": "lastReadAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_read_status_user_id_idx": { + "name": "channel_read_status_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "channel_read_status_channel_id_idx": { + "name": "channel_read_status_channel_id_idx", + "columns": [ + { + "expression": "channelId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_read_status_userId_users_id_fk": { + "name": "channel_read_status_userId_users_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_read_status_channelId_pages_id_fk": { + "name": "channel_read_status_channelId_pages_id_fk", + "tableFrom": "channel_read_status", + "tableTo": "pages", + "columnsFrom": [ + "channelId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_read_status_userId_channelId_pk": { + "name": "channel_read_status_userId_channelId_pk", + "columns": [ + "userId", + "channelId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_thread_followers": { + "name": "channel_thread_followers", + "schema": "", + "columns": { + "rootMessageId": { + "name": "rootMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_thread_followers_user_id_idx": { + "name": "channel_thread_followers_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_thread_followers_rootMessageId_channel_messages_id_fk": { + "name": "channel_thread_followers_rootMessageId_channel_messages_id_fk", + "tableFrom": "channel_thread_followers", + "tableTo": "channel_messages", + "columnsFrom": [ + "rootMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_thread_followers_userId_users_id_fk": { + "name": "channel_thread_followers_userId_users_id_fk", + "tableFrom": "channel_thread_followers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_thread_followers_rootMessageId_userId_pk": { + "name": "channel_thread_followers_rootMessageId_userId_pk", + "columns": [ + "rootMessageId", + "userId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulse_summaries": { + "name": "pulse_summaries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting": { + "name": "greeting", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "pulse_summary_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "contextData": { + "name": "contextData", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "periodStart": { + "name": "periodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "periodEnd": { + "name": "periodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "generatedAt": { + "name": "generatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_pulse_summaries_user_id": { + "name": "idx_pulse_summaries_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_generated_at": { + "name": "idx_pulse_summaries_generated_at", + "columns": [ + { + "expression": "generatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_expires_at": { + "name": "idx_pulse_summaries_expires_at", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pulse_summaries_user_generated": { + "name": "idx_pulse_summaries_user_generated", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulse_summaries_userId_users_id_fk": { + "name": "pulse_summaries_userId_users_id_fk", + "tableFrom": "pulse_summaries", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_dashboards": { + "name": "user_dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_dashboards_userId_users_id_fk": { + "name": "user_dashboards_userId_users_id_fk", + "tableFrom": "user_dashboards", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_dashboards_userId_unique": { + "name": "user_dashboards_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contextId": { + "name": "contextId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rev": { + "name": "rev", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "planPageId": { + "name": "planPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "isShared": { + "name": "isShared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "conversations_user_id_idx": { + "name": "conversations_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_user_id_type_idx": { + "name": "conversations_user_id_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_user_id_last_message_at_idx": { + "name": "conversations_user_id_last_message_at_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_context_id_idx": { + "name": "conversations_context_id_idx", + "columns": [ + { + "expression": "contextId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_plan_page_id_idx": { + "name": "conversations_plan_page_id_idx", + "columns": [ + { + "expression": "planPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_page_id_idx": { + "name": "conversations_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_userId_users_id_fk": { + "name": "conversations_userId_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_agentPageId_pages_id_fk": { + "name": "conversations_agentPageId_pages_id_fk", + "tableFrom": "conversations", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_planPageId_pages_id_fk": { + "name": "conversations_planPageId_pages_id_fk", + "tableFrom": "conversations", + "tableTo": "pages", + "columnsFrom": [ + "planPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "conversations_global_context_null_chk": { + "name": "conversations_global_context_null_chk", + "value": "\"conversations\".\"type\" <> 'global' OR \"conversations\".\"contextId\" IS NULL" + }, + "conversations_page_context_present_chk": { + "name": "conversations_page_context_present_chk", + "value": "\"conversations\".\"type\" <> 'page' OR \"conversations\".\"contextId\" IS NOT NULL" + }, + "conversations_drive_context_present_chk": { + "name": "conversations_drive_context_present_chk", + "value": "\"conversations\".\"type\" <> 'drive' OR \"conversations\".\"contextId\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messageType": { + "name": "messageType", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolCalls": { + "name": "toolCalls", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "toolResults": { + "name": "toolResults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'complete'" + }, + "sourceAgentId": { + "name": "sourceAgentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_conversation_id_created_at_idx": { + "name": "messages_conversation_id_created_at_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_user_id_idx": { + "name": "messages_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversationId_conversations_id_fk": { + "name": "messages_conversationId_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_userId_users_id_fk": { + "name": "messages_userId_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sourceAgentId_pages_id_fk": { + "name": "messages_sourceAgentId_pages_id_fk", + "tableFrom": "messages", + "tableTo": "pages", + "columnsFrom": [ + "sourceAgentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isRead": { + "name": "isRead", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggeredByUserId": { + "name": "triggeredByUserId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notifications_user_id_idx": { + "name": "notifications_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_id_is_read_idx": { + "name": "notifications_user_id_is_read_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_id_is_read_created_at_idx": { + "name": "notifications_user_id_is_read_created_at_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_type_idx": { + "name": "notifications_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_userId_users_id_fk": { + "name": "notifications_userId_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_pageId_pages_id_fk": { + "name": "notifications_pageId_pages_id_fk", + "tableFrom": "notifications", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_driveId_drives_id_fk": { + "name": "notifications_driveId_drives_id_fk", + "tableFrom": "notifications", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_triggeredByUserId_users_id_fk": { + "name": "notifications_triggeredByUserId_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "triggeredByUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_notification_log": { + "name": "email_notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notificationId": { + "name": "notificationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notificationType": { + "name": "notificationType", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipientEmail": { + "name": "recipientEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sentAt": { + "name": "sentAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_notification_log_user_idx": { + "name": "email_notification_log_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_log_sent_at_idx": { + "name": "email_notification_log_sent_at_idx", + "columns": [ + { + "expression": "sentAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_notification_log_notification_id_idx": { + "name": "email_notification_log_notification_id_idx", + "columns": [ + { + "expression": "notificationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_log_userId_users_id_fk": { + "name": "email_notification_log_userId_users_id_fk", + "tableFrom": "email_notification_log", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_notification_preferences": { + "name": "email_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notificationType": { + "name": "notificationType", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "emailEnabled": { + "name": "emailEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_notification_preferences_user_type_idx": { + "name": "email_notification_preferences_user_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notificationType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_notification_preferences_userId_users_id_fk": { + "name": "email_notification_preferences_userId_users_id_fk", + "tableFrom": "email_notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_toast_notification_preferences": { + "name": "user_toast_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "toast_notification_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_toast_notification_preferences_user_idx": { + "name": "user_toast_notification_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_toast_notification_preferences_userId_users_id_fk": { + "name": "user_toast_notification_preferences_userId_users_id_fk", + "tableFrom": "user_toast_notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.display_preferences": { + "name": "display_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "preferenceType": { + "name": "preferenceType", + "type": "display_preference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "display_preferences_user_type_idx": { + "name": "display_preferences_user_type_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "preferenceType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "display_preferences_userId_users_id_fk": { + "name": "display_preferences_userId_users_id_fk", + "tableFrom": "display_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorEmail": { + "name": "actorEmail", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy@unknown'" + }, + "actorDisplayName": { + "name": "actorDisplayName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isAiGenerated": { + "name": "isAiGenerated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "aiProvider": { + "name": "aiProvider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiModel": { + "name": "aiModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiConversationId": { + "name": "aiConversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resourceType": { + "name": "resourceType", + "type": "activity_resource", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resourceId": { + "name": "resourceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resourceTitle": { + "name": "resourceTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentSnapshot": { + "name": "contentSnapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentFormat": { + "name": "contentFormat", + "type": "content_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentRef": { + "name": "contentRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentSize": { + "name": "contentSize", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rollbackFromActivityId": { + "name": "rollbackFromActivityId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceOperation": { + "name": "rollbackSourceOperation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceTimestamp": { + "name": "rollbackSourceTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rollbackSourceTitle": { + "name": "rollbackSourceTitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updatedFields": { + "name": "updatedFields", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "previousValues": { + "name": "previousValues", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "newValues": { + "name": "newValues", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "streamId": { + "name": "streamId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "streamSeq": { + "name": "streamSeq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "stateHashBefore": { + "name": "stateHashBefore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stateHashAfter": { + "name": "stateHashAfter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dataCategory": { + "name": "dataCategory", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legalBasis": { + "name": "legalBasis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retentionPolicy": { + "name": "retentionPolicy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isArchived": { + "name": "isArchived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "chainSeq": { + "name": "chainSeq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "previousLogHash": { + "name": "previousLogHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logHash": { + "name": "logHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chainSeed": { + "name": "chainSeed", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_activity_logs_timestamp": { + "name": "idx_activity_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_user_timestamp": { + "name": "idx_activity_logs_user_timestamp", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_drive_timestamp": { + "name": "idx_activity_logs_drive_timestamp", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_page_timestamp": { + "name": "idx_activity_logs_page_timestamp", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_archived": { + "name": "idx_activity_logs_archived", + "columns": [ + { + "expression": "isArchived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_rollback_from": { + "name": "idx_activity_logs_rollback_from", + "columns": [ + { + "expression": "rollbackFromActivityId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_stream": { + "name": "idx_activity_logs_stream", + "columns": [ + { + "expression": "streamId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "streamSeq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"streamId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_change_group": { + "name": "idx_activity_logs_change_group", + "columns": [ + { + "expression": "changeGroupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"changeGroupId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_log_hash": { + "name": "idx_activity_logs_log_hash", + "columns": [ + { + "expression": "logHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"activity_logs\".\"logHash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_activity_logs_chain_seq": { + "name": "idx_activity_logs_chain_seq", + "columns": [ + { + "expression": "chainSeq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_logs_userId_users_id_fk": { + "name": "activity_logs_userId_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_logs_driveId_drives_id_fk": { + "name": "activity_logs_driveId_drives_id_fk", + "tableFrom": "activity_logs", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_logs_pageId_pages_id_fk": { + "name": "activity_logs_pageId_pages_id_fk", + "tableFrom": "activity_logs", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "activity_logs_content_size_limit": { + "name": "activity_logs_content_size_limit", + "value": "\"activity_logs\".\"contentRef\" IS NOT NULL OR \"activity_logs\".\"contentSize\" IS NULL OR \"activity_logs\".\"contentSize\" <= 1048576" + }, + "activity_logs_stream_pair": { + "name": "activity_logs_stream_pair", + "value": "(\"activity_logs\".\"streamId\" IS NULL) = (\"activity_logs\".\"streamSeq\" IS NULL)" + }, + "activity_logs_change_group_pair": { + "name": "activity_logs_change_group_pair", + "value": "(\"activity_logs\".\"changeGroupId\" IS NULL) = (\"activity_logs\".\"changeGroupType\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.ai_usage_logs": { + "name": "ai_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USD'" + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "streaming_duration": { + "name": "streaming_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "context_messages": { + "name": "context_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "context_size": { + "name": "context_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_tokens": { + "name": "system_prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tool_definition_tokens": { + "name": "tool_definition_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_tokens": { + "name": "conversation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "was_truncated": { + "name": "was_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "truncation_strategy": { + "name": "truncation_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_status": { + "name": "reconcile_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_attempts": { + "name": "reconcile_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ai_usage_timestamp": { + "name": "idx_ai_usage_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_user_id": { + "name": "idx_ai_usage_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_user_source": { + "name": "idx_ai_usage_user_source", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_provider": { + "name": "idx_ai_usage_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_cost": { + "name": "idx_ai_usage_cost", + "columns": [ + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_conversation": { + "name": "idx_ai_usage_conversation", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_context": { + "name": "idx_ai_usage_context", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_context_size": { + "name": "idx_ai_usage_context_size", + "columns": [ + { + "expression": "context_size", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_expires_at": { + "name": "idx_ai_usage_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ai_usage_reconcile": { + "name": "idx_ai_usage_reconcile", + "columns": [ + { + "expression": "reconcile_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "reconcile_status = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_metrics": { + "name": "api_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_size": { + "name": "request_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_size": { + "name": "response_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_hit": { + "name": "cache_hit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cache_key": { + "name": "cache_key", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_metrics_timestamp": { + "name": "idx_api_metrics_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_endpoint": { + "name": "idx_api_metrics_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_user_id": { + "name": "idx_api_metrics_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_status": { + "name": "idx_api_metrics_status", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_api_metrics_duration": { + "name": "idx_api_metrics_duration", + "columns": [ + { + "expression": "duration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_logs": { + "name": "error_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line": { + "name": "line", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "column": { + "name": "column", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_errors_timestamp": { + "name": "idx_errors_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_name": { + "name": "idx_errors_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_user_id": { + "name": "idx_errors_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_resolved": { + "name": "idx_errors_resolved", + "columns": [ + { + "expression": "resolved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_errors_endpoint": { + "name": "idx_errors_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_resolutions": { + "name": "error_resolutions", + "schema": "", + "columns": { + "error_id": { + "name": "error_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.siem_delivery_cursors": { + "name": "siem_delivery_cursors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "lastDeliveredId": { + "name": "lastDeliveredId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastDeliveredAt": { + "name": "lastDeliveredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastErrorAt": { + "name": "lastErrorAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deliveryCount": { + "name": "deliveryCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "siem_delivery_cursors_delivered_cursor_pair": { + "name": "siem_delivery_cursors_delivered_cursor_pair", + "value": "(\"siem_delivery_cursors\".\"lastDeliveredId\" IS NULL) = (\"siem_delivery_cursors\".\"lastDeliveredAt\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.siem_delivery_receipts": { + "name": "siem_delivery_receipts", + "schema": "", + "columns": { + "receiptId": { + "name": "receiptId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deliveryId": { + "name": "deliveryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "firstEntryId": { + "name": "firstEntryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastEntryId": { + "name": "lastEntryId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "firstEntryTimestamp": { + "name": "firstEntryTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "lastEntryTimestamp": { + "name": "lastEntryTimestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "entryCount": { + "name": "entryCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deliveredAt": { + "name": "deliveredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "webhookStatus": { + "name": "webhookStatus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "webhookResponseHash": { + "name": "webhookResponseHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ackReceivedAt": { + "name": "ackReceivedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "siem_delivery_receipts_delivery_source_unique": { + "name": "siem_delivery_receipts_delivery_source_unique", + "columns": [ + { + "expression": "deliveryId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_delivery_id": { + "name": "idx_siem_receipts_delivery_id", + "columns": [ + { + "expression": "deliveryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_first_entry": { + "name": "idx_siem_receipts_first_entry", + "columns": [ + { + "expression": "firstEntryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_last_entry": { + "name": "idx_siem_receipts_last_entry", + "columns": [ + { + "expression": "lastEntryId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_delivered_at": { + "name": "idx_siem_receipts_delivered_at", + "columns": [ + { + "expression": "deliveredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_siem_receipts_source_range": { + "name": "idx_siem_receipts_source_range", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "firstEntryTimestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastEntryTimestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_logs": { + "name": "system_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "level": { + "name": "level", + "type": "log_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_name": { + "name": "error_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memory_used": { + "name": "memory_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "memory_total": { + "name": "memory_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_system_logs_timestamp": { + "name": "idx_system_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_level": { + "name": "idx_system_logs_level", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_category": { + "name": "idx_system_logs_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_user_id": { + "name": "idx_system_logs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_request_id": { + "name": "idx_system_logs_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_logs_error": { + "name": "idx_system_logs_error", + "columns": [ + { + "expression": "error_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_activities": { + "name": "user_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_activities_timestamp": { + "name": "idx_user_activities_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_user_id": { + "name": "idx_user_activities_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_action": { + "name": "idx_user_activities_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_activities_resource": { + "name": "idx_user_activities_resource", + "columns": [ + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_files": { + "name": "drive_backup_files", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storagePath": { + "name": "storagePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sizeBytes": { + "name": "sizeBytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checksumVersion": { + "name": "checksumVersion", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_files_backup_idx": { + "name": "drive_backup_files_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_files_backupId_drive_backups_id_fk": { + "name": "drive_backup_files_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_files", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_files_backupId_fileId_pk": { + "name": "drive_backup_files_backupId_fileId_pk", + "columns": [ + "backupId", + "fileId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_members": { + "name": "drive_backup_members", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customRoleId": { + "name": "customRoleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedBy": { + "name": "invitedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_members_backup_idx": { + "name": "drive_backup_members_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_members_backupId_drive_backups_id_fk": { + "name": "drive_backup_members_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_members", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_members_backupId_userId_pk": { + "name": "drive_backup_members_backupId_userId_pk", + "columns": [ + "backupId", + "userId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_pages": { + "name": "drive_backup_pages", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageVersionId": { + "name": "pageVersionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalParentId": { + "name": "originalParentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_pages_backup_idx": { + "name": "drive_backup_pages_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_pages_backupId_drive_backups_id_fk": { + "name": "drive_backup_pages_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_pages", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_backup_pages_pageVersionId_page_versions_id_fk": { + "name": "drive_backup_pages_pageVersionId_page_versions_id_fk", + "tableFrom": "drive_backup_pages", + "tableTo": "page_versions", + "columnsFrom": [ + "pageVersionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_pages_backupId_pageId_pk": { + "name": "drive_backup_pages_backupId_pageId_pk", + "columns": [ + "backupId", + "pageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_permissions": { + "name": "drive_backup_permissions", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canView": { + "name": "canView", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "canEdit": { + "name": "canEdit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canShare": { + "name": "canShare", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canDelete": { + "name": "canDelete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grantedBy": { + "name": "grantedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_permissions_backup_idx": { + "name": "drive_backup_permissions_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_permissions_backupId_drive_backups_id_fk": { + "name": "drive_backup_permissions_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_permissions", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_permissions_backupId_pageId_userId_pk": { + "name": "drive_backup_permissions_backupId_pageId_userId_pk", + "columns": [ + "backupId", + "pageId", + "userId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_roles": { + "name": "drive_backup_roles", + "schema": "", + "columns": { + "backupId": { + "name": "backupId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roleId": { + "name": "roleId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drive_wide_permissions": { + "name": "drive_wide_permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "NULL" + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backup_roles_backup_idx": { + "name": "drive_backup_roles_backup_idx", + "columns": [ + { + "expression": "backupId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_roles_backupId_drive_backups_id_fk": { + "name": "drive_backup_roles_backupId_drive_backups_id_fk", + "tableFrom": "drive_backup_roles", + "tableTo": "drive_backups", + "columnsFrom": [ + "backupId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "drive_backup_roles_backupId_roleId_pk": { + "name": "drive_backup_roles_backupId_roleId_pk", + "columns": [ + "backupId", + "roleId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backup_schedules": { + "name": "drive_backup_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "frequency": { + "name": "frequency", + "type": "drive_backup_schedule_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'daily'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastRunAt": { + "name": "lastRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "drive_backup_schedules_enabled_next_run_idx": { + "name": "drive_backup_schedules_enabled_next_run_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backup_schedules_driveId_drives_id_fk": { + "name": "drive_backup_schedules_driveId_drives_id_fk", + "tableFrom": "drive_backup_schedules", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_backup_schedules_driveId_unique": { + "name": "drive_backup_schedules_driveId_unique", + "nullsNotDistinct": false, + "columns": [ + "driveId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_backups": { + "name": "drive_backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "drive_backup_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "status": { + "name": "status", + "type": "drive_backup_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "isPinned": { + "name": "isPinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAt": { + "name": "failedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failureReason": { + "name": "failureReason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "drive_backups_drive_created_at_idx": { + "name": "drive_backups_drive_created_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_backups_status_idx": { + "name": "drive_backups_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_backups_driveId_drives_id_fk": { + "name": "drive_backups_driveId_drives_id_fk", + "tableFrom": "drive_backups", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_backups_createdBy_users_id_fk": { + "name": "drive_backups_createdBy_users_id_fk", + "tableFrom": "drive_backups", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "drive_backups_change_group_pair": { + "name": "drive_backups_change_group_pair", + "value": "(\"drive_backups\".\"changeGroupId\" IS NULL) = (\"drive_backups\".\"changeGroupType\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.page_versions": { + "name": "page_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "page_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupType": { + "name": "changeGroupType", + "type": "activity_change_group_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentRef": { + "name": "contentRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentFormat": { + "name": "contentFormat", + "type": "content_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "contentSize": { + "name": "contentSize", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stateHash": { + "name": "stateHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageRevision": { + "name": "pageRevision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "isPinned": { + "name": "isPinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_versions_page_created_at_idx": { + "name": "page_versions_page_created_at_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_drive_created_at_idx": { + "name": "page_versions_drive_created_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_pinned_idx": { + "name": "page_versions_pinned_idx", + "columns": [ + { + "expression": "isPinned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_versions_page_id_is_pinned_created_at_idx": { + "name": "page_versions_page_id_is_pinned_created_at_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isPinned", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_versions_pageId_pages_id_fk": { + "name": "page_versions_pageId_pages_id_fk", + "tableFrom": "page_versions", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_driveId_drives_id_fk": { + "name": "page_versions_driveId_drives_id_fk", + "tableFrom": "page_versions", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_createdBy_users_id_fk": { + "name": "page_versions_createdBy_users_id_fk", + "tableFrom": "page_versions", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "page_versions_change_group_pair": { + "name": "page_versions_change_group_pair", + "value": "(\"page_versions\".\"changeGroupId\" IS NULL) = (\"page_versions\".\"changeGroupType\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user1Id": { + "name": "user1Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user2Id": { + "name": "user2Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "requestedBy": { + "name": "requestedBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requestMessage": { + "name": "requestMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requestedAt": { + "name": "requestedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acceptedAt": { + "name": "acceptedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "blockedBy": { + "name": "blockedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blockedAt": { + "name": "blockedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "connections_user1_id_idx": { + "name": "connections_user1_id_idx", + "columns": [ + { + "expression": "user1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user2_id_idx": { + "name": "connections_user2_id_idx", + "columns": [ + { + "expression": "user2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_status_idx": { + "name": "connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user1_status_idx": { + "name": "connections_user1_status_idx", + "columns": [ + { + "expression": "user1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user2_status_idx": { + "name": "connections_user2_status_idx", + "columns": [ + { + "expression": "user2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_user1Id_users_id_fk": { + "name": "connections_user1Id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user1Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_user2Id_users_id_fk": { + "name": "connections_user2Id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "user2Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_requestedBy_users_id_fk": { + "name": "connections_requestedBy_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "requestedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_blockedBy_users_id_fk": { + "name": "connections_blockedBy_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "columnsFrom": [ + "blockedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connections_user_pair_key": { + "name": "connections_user_pair_key", + "nullsNotDistinct": false, + "columns": [ + "user1Id", + "user2Id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "senderId": { + "name": "senderId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attachmentMeta": { + "name": "attachmentMeta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isRead": { + "name": "isRead", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isEdited": { + "name": "isEdited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "editedAt": { + "name": "editedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replyCount": { + "name": "replyCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastReplyAt": { + "name": "lastReplyAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "mirroredFromId": { + "name": "mirroredFromId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotedMessageId": { + "name": "quotedMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "direct_messages_conversation_id_idx": { + "name": "direct_messages_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_sender_id_idx": { + "name": "direct_messages_sender_id_idx", + "columns": [ + { + "expression": "senderId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_created_at_idx": { + "name": "direct_messages_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_file_id_idx": { + "name": "direct_messages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_active_created_idx": { + "name": "direct_messages_conversation_active_created_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_inactive_deleted_at_idx": { + "name": "direct_messages_inactive_deleted_at_idx", + "columns": [ + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deletedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_created_idx": { + "name": "direct_messages_conversation_created_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_conversation_is_read_idx": { + "name": "direct_messages_conversation_is_read_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_unread_count_idx": { + "name": "direct_messages_unread_count_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "senderId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isRead", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_parent_created_idx": { + "name": "direct_messages_parent_created_idx", + "columns": [ + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_quoted_id_idx": { + "name": "direct_messages_quoted_id_idx", + "columns": [ + { + "expression": "quotedMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_conversationId_dm_conversations_id_fk": { + "name": "direct_messages_conversationId_dm_conversations_id_fk", + "tableFrom": "direct_messages", + "tableTo": "dm_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_senderId_users_id_fk": { + "name": "direct_messages_senderId_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": [ + "senderId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_fileId_files_id_fk": { + "name": "direct_messages_fileId_files_id_fk", + "tableFrom": "direct_messages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "direct_messages_parentId_direct_messages_id_fk": { + "name": "direct_messages_parentId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_mirroredFromId_direct_messages_id_fk": { + "name": "direct_messages_mirroredFromId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "mirroredFromId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "direct_messages_quotedMessageId_direct_messages_id_fk": { + "name": "direct_messages_quotedMessageId_direct_messages_id_fk", + "tableFrom": "direct_messages", + "tableTo": "direct_messages", + "columnsFrom": [ + "quotedMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dm_conversations": { + "name": "dm_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "participant1Id": { + "name": "participant1Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant2Id": { + "name": "participant2Id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lastMessageAt": { + "name": "lastMessageAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastMessagePreview": { + "name": "lastMessagePreview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participant1LastRead": { + "name": "participant1LastRead", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "participant2LastRead": { + "name": "participant2LastRead", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dm_conversations_participant1_id_idx": { + "name": "dm_conversations_participant1_id_idx", + "columns": [ + { + "expression": "participant1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant2_id_idx": { + "name": "dm_conversations_participant2_id_idx", + "columns": [ + { + "expression": "participant2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_last_message_at_idx": { + "name": "dm_conversations_last_message_at_idx", + "columns": [ + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant1_last_message_idx": { + "name": "dm_conversations_participant1_last_message_idx", + "columns": [ + { + "expression": "participant1Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_conversations_participant2_last_message_idx": { + "name": "dm_conversations_participant2_last_message_idx", + "columns": [ + { + "expression": "participant2Id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastMessageAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_conversations_participant1Id_users_id_fk": { + "name": "dm_conversations_participant1Id_users_id_fk", + "tableFrom": "dm_conversations", + "tableTo": "users", + "columnsFrom": [ + "participant1Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_conversations_participant2Id_users_id_fk": { + "name": "dm_conversations_participant2Id_users_id_fk", + "tableFrom": "dm_conversations", + "tableTo": "users", + "columnsFrom": [ + "participant2Id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dm_conversations_participant_pair_key": { + "name": "dm_conversations_participant_pair_key", + "nullsNotDistinct": false, + "columns": [ + "participant1Id", + "participant2Id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dm_message_reactions": { + "name": "dm_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dm_unique_reaction_idx": { + "name": "dm_unique_reaction_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dm_reaction_message_idx": { + "name": "dm_reaction_message_idx", + "columns": [ + { + "expression": "messageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_message_reactions_messageId_direct_messages_id_fk": { + "name": "dm_message_reactions_messageId_direct_messages_id_fk", + "tableFrom": "dm_message_reactions", + "tableTo": "direct_messages", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_message_reactions_userId_users_id_fk": { + "name": "dm_message_reactions_userId_users_id_fk", + "tableFrom": "dm_message_reactions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dm_thread_followers": { + "name": "dm_thread_followers", + "schema": "", + "columns": { + "rootMessageId": { + "name": "rootMessageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dm_thread_followers_user_id_idx": { + "name": "dm_thread_followers_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dm_thread_followers_rootMessageId_direct_messages_id_fk": { + "name": "dm_thread_followers_rootMessageId_direct_messages_id_fk", + "tableFrom": "dm_thread_followers", + "tableTo": "direct_messages", + "columnsFrom": [ + "rootMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dm_thread_followers_userId_users_id_fk": { + "name": "dm_thread_followers_userId_users_id_fk", + "tableFrom": "dm_thread_followers", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dm_thread_followers_rootMessageId_userId_pk": { + "name": "dm_thread_followers_rootMessageId_userId_pk", + "columns": [ + "rootMessageId", + "userId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_type_idx": { + "name": "stripe_events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_processed_at_idx": { + "name": "stripe_events_processed_at_idx", + "columns": [ + { + "expression": "processedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "gifted": { + "name": "gifted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeScheduleId": { + "name": "stripeScheduleId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduledPriceId": { + "name": "scheduledPriceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduledChangeDate": { + "name": "scheduledChangeDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_id_idx": { + "name": "subscriptions_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_subscription_id_idx": { + "name": "subscriptions_stripe_subscription_id_idx", + "columns": [ + { + "expression": "stripeSubscriptionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_schedule_id_idx": { + "name": "subscriptions_stripe_schedule_id_idx", + "columns": [ + { + "expression": "stripeScheduleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_userId_users_id_fk": { + "name": "subscriptions_userId_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_stripeSubscriptionId_unique": { + "name": "subscriptions_stripeSubscriptionId_unique", + "nullsNotDistinct": false, + "columns": [ + "stripeSubscriptionId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_submissions": { + "name": "contact_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolvedAt": { + "name": "resolvedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contact_submissions_email_idx": { + "name": "contact_submissions_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_submissions_created_at_idx": { + "name": "contact_submissions_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_submissions": { + "name": "feedback_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "screen_size": { + "name": "screen_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "viewport_size": { + "name": "viewport_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "console_errors": { + "name": "console_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_submissions_user_id_idx": { + "name": "feedback_submissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_submissions_status_idx": { + "name": "feedback_submissions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_submissions_created_at_idx": { + "name": "feedback_submissions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_submissions_user_id_users_id_fk": { + "name": "feedback_submissions_user_id_users_id_fk", + "tableFrom": "feedback_submissions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_conversations": { + "name": "file_conversations", + "schema": "", + "columns": { + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedBy": { + "name": "linkedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedAt": { + "name": "linkedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "linkSource": { + "name": "linkSource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_conversations_file_id_idx": { + "name": "file_conversations_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_conversations_conversation_id_idx": { + "name": "file_conversations_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_conversations_fileId_files_id_fk": { + "name": "file_conversations_fileId_files_id_fk", + "tableFrom": "file_conversations", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_conversations_conversationId_dm_conversations_id_fk": { + "name": "file_conversations_conversationId_dm_conversations_id_fk", + "tableFrom": "file_conversations", + "tableTo": "dm_conversations", + "columnsFrom": [ + "conversationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_conversations_linkedBy_users_id_fk": { + "name": "file_conversations_linkedBy_users_id_fk", + "tableFrom": "file_conversations", + "tableTo": "users", + "columnsFrom": [ + "linkedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_conversations_fileId_conversationId_pk": { + "name": "file_conversations_fileId_conversationId_pk", + "columns": [ + "fileId", + "conversationId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_pages": { + "name": "file_pages", + "schema": "", + "columns": { + "fileId": { + "name": "fileId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedBy": { + "name": "linkedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedAt": { + "name": "linkedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "linkSource": { + "name": "linkSource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_pages_file_id_idx": { + "name": "file_pages_file_id_idx", + "columns": [ + { + "expression": "fileId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_pages_page_id_idx": { + "name": "file_pages_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_pages_fileId_files_id_fk": { + "name": "file_pages_fileId_files_id_fk", + "tableFrom": "file_pages", + "tableTo": "files", + "columnsFrom": [ + "fileId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_pages_pageId_pages_id_fk": { + "name": "file_pages_pageId_pages_id_fk", + "tableFrom": "file_pages", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "file_pages_linkedBy_users_id_fk": { + "name": "file_pages_linkedBy_users_id_fk", + "tableFrom": "file_pages", + "tableTo": "users", + "columnsFrom": [ + "linkedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_pages_fileId_pageId_pk": { + "name": "file_pages_fileId_pageId_pk", + "columns": [ + "fileId", + "pageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sizeBytes": { + "name": "sizeBytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "mimeType": { + "name": "mimeType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storagePath": { + "name": "storagePath", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checksumVersion": { + "name": "checksumVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastAccessedAt": { + "name": "lastAccessedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "files_drive_id_idx": { + "name": "files_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_driveId_drives_id_fk": { + "name": "files_driveId_drives_id_fk", + "tableFrom": "files", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_createdBy_users_id_fk": { + "name": "files_createdBy_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_uploads": { + "name": "pending_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fileSize": { + "name": "fileSize", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pending_uploads_user_expires_idx": { + "name": "pending_uploads_user_expires_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_uploads_expires_idx": { + "name": "pending_uploads_expires_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_uploads_userId_users_id_fk": { + "name": "pending_uploads_userId_users_id_fk", + "tableFrom": "pending_uploads", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_assignees": { + "name": "task_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "taskId": { + "name": "taskId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_assignees_task_id_idx": { + "name": "task_assignees_task_id_idx", + "columns": [ + { + "expression": "taskId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_assignees_user_id_idx": { + "name": "task_assignees_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_assignees_agent_page_id_idx": { + "name": "task_assignees_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_assignees_taskId_task_items_id_fk": { + "name": "task_assignees_taskId_task_items_id_fk", + "tableFrom": "task_assignees", + "tableTo": "task_items", + "columnsFrom": [ + "taskId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_userId_users_id_fk": { + "name": "task_assignees_userId_users_id_fk", + "tableFrom": "task_assignees", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_assignees_agentPageId_pages_id_fk": { + "name": "task_assignees_agentPageId_pages_id_fk", + "tableFrom": "task_assignees", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_assignees_task_user": { + "name": "task_assignees_task_user", + "nullsNotDistinct": false, + "columns": [ + "taskId", + "userId" + ] + }, + "task_assignees_task_agent": { + "name": "task_assignees_task_agent", + "nullsNotDistinct": false, + "columns": [ + "taskId", + "agentPageId" + ] + } + }, + "policies": {}, + "checkConstraints": { + "task_assignees_has_assignee": { + "name": "task_assignees_has_assignee", + "value": "(\"userId\" IS NOT NULL OR \"agentPageId\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.task_items": { + "name": "task_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigneeId": { + "name": "assigneeId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigneeAgentId": { + "name": "assigneeAgentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_items_assignee_id_idx": { + "name": "task_items_assignee_id_idx", + "columns": [ + { + "expression": "assigneeId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_assignee_agent_id_idx": { + "name": "task_items_assignee_agent_id_idx", + "columns": [ + { + "expression": "assigneeAgentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_page_id_idx": { + "name": "task_items_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_items_due_date_idx": { + "name": "task_items_due_date_idx", + "columns": [ + { + "expression": "dueDate", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_items_userId_users_id_fk": { + "name": "task_items_userId_users_id_fk", + "tableFrom": "task_items", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_items_assigneeId_users_id_fk": { + "name": "task_items_assigneeId_users_id_fk", + "tableFrom": "task_items", + "tableTo": "users", + "columnsFrom": [ + "assigneeId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_items_assigneeAgentId_pages_id_fk": { + "name": "task_items_assigneeAgentId_pages_id_fk", + "tableFrom": "task_items", + "tableTo": "pages", + "columnsFrom": [ + "assigneeAgentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_items_pageId_pages_id_fk": { + "name": "task_items_pageId_pages_id_fk", + "tableFrom": "task_items", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_items_pageId_unique": { + "name": "task_items_pageId_unique", + "nullsNotDistinct": false, + "columns": [ + "pageId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_lists": { + "name": "task_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_lists_page_id_idx": { + "name": "task_lists_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_lists_conversation_id_idx": { + "name": "task_lists_conversation_id_idx", + "columns": [ + { + "expression": "conversationId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_lists_user_id_idx": { + "name": "task_lists_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_lists_userId_users_id_fk": { + "name": "task_lists_userId_users_id_fk", + "tableFrom": "task_lists", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_lists_pageId_pages_id_fk": { + "name": "task_lists_pageId_pages_id_fk", + "tableFrom": "task_lists", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_status_configs": { + "name": "task_status_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "taskListId": { + "name": "taskListId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_status_configs_task_list_id_idx": { + "name": "task_status_configs_task_list_id_idx", + "columns": [ + { + "expression": "taskListId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_status_configs_taskListId_task_lists_id_fk": { + "name": "task_status_configs_taskListId_task_lists_id_fk", + "tableFrom": "task_status_configs", + "tableTo": "task_lists", + "columnsFrom": [ + "taskListId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_status_configs_task_list_slug": { + "name": "task_status_configs_task_list_slug", + "nullsNotDistinct": false, + "columns": [ + "taskListId", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sheet_cell_deps": { + "name": "sheet_cell_deps", + "schema": "", + "columns": { + "tabId": { + "name": "tabId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dependsOn": { + "name": "dependsOn", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "dependents": { + "name": "dependents", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sheet_cell_deps_tab_idx": { + "name": "sheet_cell_deps_tab_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sheet_cell_deps_tabId_sheet_tabs_id_fk": { + "name": "sheet_cell_deps_tabId_sheet_tabs_id_fk", + "tableFrom": "sheet_cell_deps", + "tableTo": "sheet_tabs", + "columnsFrom": [ + "tabId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sheet_cell_deps_tabId_address_pk": { + "name": "sheet_cell_deps_tabId_address_pk", + "columns": [ + "tabId", + "address" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sheet_changes": { + "name": "sheet_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tabId": { + "name": "tabId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "actorUserId": { + "name": "actorUserId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actorEmail": { + "name": "actorEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changeGroupId": { + "name": "changeGroupId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "op": { + "name": "op", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rowIndex": { + "name": "rowIndex", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sheet_changes_page_seq_idx": { + "name": "sheet_changes_page_seq_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_changes_tab_seq_idx": { + "name": "sheet_changes_tab_seq_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_changes_created_at_idx": { + "name": "sheet_changes_created_at_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sheet_changes_pageId_pages_id_fk": { + "name": "sheet_changes_pageId_pages_id_fk", + "tableFrom": "sheet_changes", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sheet_changes_actorUserId_users_id_fk": { + "name": "sheet_changes_actorUserId_users_id_fk", + "tableFrom": "sheet_changes", + "tableTo": "users", + "columnsFrom": [ + "actorUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sheet_range_deps": { + "name": "sheet_range_deps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tabId": { + "name": "tabId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formulaAddress": { + "name": "formulaAddress", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rowStart": { + "name": "rowStart", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rowEnd": { + "name": "rowEnd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "colStart": { + "name": "colStart", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "colEnd": { + "name": "colEnd", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sheet_range_deps_tab_idx": { + "name": "sheet_range_deps_tab_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_range_deps_cover_idx": { + "name": "sheet_range_deps_cover_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rowStart", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rowEnd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_range_deps_formula_idx": { + "name": "sheet_range_deps_formula_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "formulaAddress", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sheet_range_deps_tabId_sheet_tabs_id_fk": { + "name": "sheet_range_deps_tabId_sheet_tabs_id_fk", + "tableFrom": "sheet_range_deps", + "tableTo": "sheet_tabs", + "columnsFrom": [ + "tabId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sheet_range_deps_bounds_ordered": { + "name": "sheet_range_deps_bounds_ordered", + "value": "(\"sheet_range_deps\".\"rowEnd\" IS NULL OR \"sheet_range_deps\".\"rowEnd\" >= \"sheet_range_deps\".\"rowStart\") AND (\"sheet_range_deps\".\"colEnd\" IS NULL OR \"sheet_range_deps\".\"colEnd\" >= \"sheet_range_deps\".\"colStart\")" + } + }, + "isRLSEnabled": false + }, + "public.sheet_rows": { + "name": "sheet_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tabId": { + "name": "tabId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rowIndex": { + "name": "rowIndex", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cells": { + "name": "cells", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sheet_rows_page_row_idx": { + "name": "sheet_rows_page_row_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rowIndex", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_rows_tab_row_idx": { + "name": "sheet_rows_tab_row_idx", + "columns": [ + { + "expression": "tabId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rowIndex", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sheet_rows_cells_gin": { + "name": "sheet_rows_cells_gin", + "columns": [ + { + "expression": "cells", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "sheet_rows_tabId_sheet_tabs_id_fk": { + "name": "sheet_rows_tabId_sheet_tabs_id_fk", + "tableFrom": "sheet_rows", + "tableTo": "sheet_tabs", + "columnsFrom": [ + "tabId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sheet_rows_pageId_pages_id_fk": { + "name": "sheet_rows_pageId_pages_id_fk", + "tableFrom": "sheet_rows", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sheet_rows_tab_row_unique": { + "name": "sheet_rows_tab_row_unique", + "nullsNotDistinct": false, + "columns": [ + "tabId", + "rowIndex" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sheet_rows_row_index_non_negative": { + "name": "sheet_rows_row_index_non_negative", + "value": "\"sheet_rows\".\"rowIndex\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sheet_tabs": { + "name": "sheet_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tabIndex": { + "name": "tabIndex", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rowCount": { + "name": "rowCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "columnCount": { + "name": "columnCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frozenRows": { + "name": "frozenRows", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frozenColumns": { + "name": "frozenColumns", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "columnFormats": { + "name": "columnFormats", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "columnWidths": { + "name": "columnWidths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rowHeights": { + "name": "rowHeights", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ranges": { + "name": "ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "conditionalFormats": { + "name": "conditionalFormats", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sheet_tabs_page_id_idx": { + "name": "sheet_tabs_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sheet_tabs_pageId_pages_id_fk": { + "name": "sheet_tabs_pageId_pages_id_fk", + "tableFrom": "sheet_tabs", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sheet_tabs_page_tab_unique": { + "name": "sheet_tabs_page_tab_unique", + "nullsNotDistinct": false, + "columns": [ + "pageId", + "tabIndex" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sheet_tabs_extent_non_negative": { + "name": "sheet_tabs_extent_non_negative", + "value": "\"sheet_tabs\".\"rowCount\" >= 0 AND \"sheet_tabs\".\"columnCount\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_bidx": { + "name": "ip_bidx", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_location": { + "name": "geo_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "anomaly_flags": { + "name": "anomaly_flags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "chain_seq": { + "name": "chain_seq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "previous_hash": { + "name": "previous_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_hash": { + "name": "event_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_security_audit_timestamp": { + "name": "idx_security_audit_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_user_timestamp": { + "name": "idx_security_audit_user_timestamp", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_event_type": { + "name": "idx_security_audit_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_resource": { + "name": "idx_security_audit_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_ip": { + "name": "idx_security_audit_ip", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_ip_bidx": { + "name": "idx_security_audit_ip_bidx", + "columns": [ + { + "expression": "ip_bidx", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_event_hash": { + "name": "idx_security_audit_event_hash", + "columns": [ + { + "expression": "event_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_chain_seq": { + "name": "idx_security_audit_chain_seq", + "columns": [ + { + "expression": "chain_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_risk_score": { + "name": "idx_security_audit_risk_score", + "columns": [ + { + "expression": "risk_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_audit_session": { + "name": "idx_security_audit_session", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_user_id_users_id_fk": { + "name": "security_audit_log_user_id_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_page_views": { + "name": "user_page_views", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "viewedAt": { + "name": "viewedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_page_views_user_id_idx": { + "name": "user_page_views_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_page_views_page_id_idx": { + "name": "user_page_views_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_page_views_user_page_idx": { + "name": "user_page_views_user_page_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_page_views_userId_users_id_fk": { + "name": "user_page_views_userId_users_id_fk", + "tableFrom": "user_page_views", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_page_views_pageId_pages_id_fk": { + "name": "user_page_views_pageId_pages_id_fk", + "tableFrom": "user_page_views", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_page_views_userId_pageId_pk": { + "name": "user_page_views_userId_pageId_pk", + "columns": [ + "userId", + "pageId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_hotkey_preferences": { + "name": "user_hotkey_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hotkeyId": { + "name": "hotkeyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_hotkey_preferences_user_hotkey_idx": { + "name": "user_hotkey_preferences_user_hotkey_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hotkeyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_hotkey_preferences_user_idx": { + "name": "user_hotkey_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_hotkey_preferences_userId_users_id_fk": { + "name": "user_hotkey_preferences_userId_users_id_fk", + "tableFrom": "user_hotkey_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_notification_tokens": { + "name": "push_notification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "PushPlatformType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "deviceId": { + "name": "deviceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deviceName": { + "name": "deviceName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "webPushSubscription": { + "name": "webPushSubscription", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failedAttempts": { + "name": "failedAttempts", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "lastFailedAt": { + "name": "lastFailedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "push_notification_tokens_user_id_idx": { + "name": "push_notification_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_token_idx": { + "name": "push_notification_tokens_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_platform_idx": { + "name": "push_notification_tokens_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_notification_tokens_active_idx": { + "name": "push_notification_tokens_active_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isActive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_notification_tokens_userId_users_id_fk": { + "name": "push_notification_tokens_userId_users_id_fk", + "tableFrom": "push_notification_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.global_assistant_config": { + "name": "global_assistant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_user_integrations": { + "name": "enabled_user_integrations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "drive_overrides": { + "name": "drive_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inherit_drive_integrations": { + "name": "inherit_drive_integrations", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "global_assistant_config_user_id_users_id_fk": { + "name": "global_assistant_config_user_id_users_id_fk", + "tableFrom": "global_assistant_config", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "global_assistant_config_user_id_unique": { + "name": "global_assistant_config_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_audit_log": { + "name": "integration_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_summary": { + "name": "input_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_audit_log_drive_id_idx": { + "name": "integration_audit_log_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_connection_id_idx": { + "name": "integration_audit_log_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_created_at_idx": { + "name": "integration_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_audit_log_drive_created_at_idx": { + "name": "integration_audit_log_drive_created_at_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_audit_log_drive_id_drives_id_fk": { + "name": "integration_audit_log_drive_id_drives_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_audit_log_agent_id_pages_id_fk": { + "name": "integration_audit_log_agent_id_pages_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "pages", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_audit_log_user_id_users_id_fk": { + "name": "integration_audit_log_user_id_users_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_audit_log_connection_id_integration_connections_id_fk": { + "name": "integration_audit_log_connection_id_integration_connections_id_fk", + "tableFrom": "integration_audit_log", + "tableTo": "integration_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integration_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "base_url_override": { + "name": "base_url_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_overrides": { + "name": "config_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "account_metadata": { + "name": "account_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "integration_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'owned_drives'" + }, + "oauth_state": { + "name": "oauth_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_provider_id_idx": { + "name": "integration_connections_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_user_id_idx": { + "name": "integration_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_drive_id_idx": { + "name": "integration_connections_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_provider_id_integration_providers_id_fk": { + "name": "integration_connections_provider_id_integration_providers_id_fk", + "tableFrom": "integration_connections", + "tableTo": "integration_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_user_id_users_id_fk": { + "name": "integration_connections_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_drive_id_drives_id_fk": { + "name": "integration_connections_drive_id_drives_id_fk", + "tableFrom": "integration_connections", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_users_id_fk": { + "name": "integration_connections_connected_by_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "columnsFrom": [ + "connected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_user_provider": { + "name": "integration_connections_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider_id" + ] + }, + "integration_connections_drive_provider": { + "name": "integration_connections_drive_provider", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "integration_connections_scope_chk": { + "name": "integration_connections_scope_chk", + "value": "(\"integration_connections\".\"user_id\" IS NOT NULL AND \"integration_connections\".\"drive_id\" IS NULL) OR (\"integration_connections\".\"user_id\" IS NULL AND \"integration_connections\".\"drive_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.integration_providers": { + "name": "integration_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "documentation_url": { + "name": "documentation_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "integration_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_providers_slug_idx": { + "name": "integration_providers_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_providers_drive_id_idx": { + "name": "integration_providers_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_providers_created_by_users_id_fk": { + "name": "integration_providers_created_by_users_id_fk", + "tableFrom": "integration_providers", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "integration_providers_drive_id_drives_id_fk": { + "name": "integration_providers_drive_id_drives_id_fk", + "tableFrom": "integration_providers", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_providers_slug_unique": { + "name": "integration_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_tool_grants": { + "name": "integration_tool_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "denied_tools": { + "name": "denied_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_only": { + "name": "read_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_override": { + "name": "rate_limit_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_tool_grants_agent_id_idx": { + "name": "integration_tool_grants_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_tool_grants_connection_id_idx": { + "name": "integration_tool_grants_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_tool_grants_agent_id_pages_id_fk": { + "name": "integration_tool_grants_agent_id_pages_id_fk", + "tableFrom": "integration_tool_grants", + "tableTo": "pages", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_tool_grants_connection_id_integration_connections_id_fk": { + "name": "integration_tool_grants_connection_id_integration_connections_id_fk", + "tableFrom": "integration_tool_grants", + "tableTo": "integration_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_tool_grants_agent_connection": { + "name": "integration_tool_grants_agent_connection", + "nullsNotDistinct": false, + "columns": [ + "agent_id", + "connection_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personalization_candidates": { + "name": "personalization_candidates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim": { + "name": "claim", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimKey": { + "name": "claimKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurrences": { + "name": "occurrences", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "firstSeenAt": { + "name": "firstSeenAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastSeenAt": { + "name": "lastSeenAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "promotedAt": { + "name": "promotedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejectedAt": { + "name": "rejectedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "personalization_candidates_user_field_claim_idx": { + "name": "personalization_candidates_user_field_claim_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personalization_candidates_userId_users_id_fk": { + "name": "personalization_candidates_userId_users_id_fk", + "tableFrom": "personalization_candidates", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "personalization_candidates_field_chk": { + "name": "personalization_candidates_field_chk", + "value": "\"personalization_candidates\".\"field\" IN ('bio', 'writingStyle', 'rules')" + } + }, + "isRLSEnabled": false + }, + "public.user_personalization": { + "name": "user_personalization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "writingStyle": { + "name": "writingStyle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rules": { + "name": "rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memoryFolderId": { + "name": "memoryFolderId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bioPageId": { + "name": "bioPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "writingStylePageId": { + "name": "writingStylePageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rulesPageId": { + "name": "rulesPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_personalization_user_idx": { + "name": "user_personalization_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_personalization_userId_users_id_fk": { + "name": "user_personalization_userId_users_id_fk", + "tableFrom": "user_personalization", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_personalization_memoryFolderId_pages_id_fk": { + "name": "user_personalization_memoryFolderId_pages_id_fk", + "tableFrom": "user_personalization", + "tableTo": "pages", + "columnsFrom": [ + "memoryFolderId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_personalization_bioPageId_pages_id_fk": { + "name": "user_personalization_bioPageId_pages_id_fk", + "tableFrom": "user_personalization", + "tableTo": "pages", + "columnsFrom": [ + "bioPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_personalization_writingStylePageId_pages_id_fk": { + "name": "user_personalization_writingStylePageId_pages_id_fk", + "tableFrom": "user_personalization", + "tableTo": "pages", + "columnsFrom": [ + "writingStylePageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_personalization_rulesPageId_pages_id_fk": { + "name": "user_personalization_rulesPageId_pages_id_fk", + "tableFrom": "user_personalization", + "tableTo": "pages", + "columnsFrom": [ + "rulesPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_automation_preferences": { + "name": "user_automation_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pulseEnabled": { + "name": "pulseEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_automation_preferences_user_idx": { + "name": "user_automation_preferences_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_automation_preferences_userId_users_id_fk": { + "name": "user_automation_preferences_userId_users_id_fk", + "tableFrom": "user_automation_preferences", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_event_drives": { + "name": "calendar_event_drives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharedBy": { + "name": "sharedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sharedAt": { + "name": "sharedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_event_drives_event_id_idx": { + "name": "calendar_event_drives_event_id_idx", + "columns": [ + { + "expression": "eventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_event_drives_drive_id_idx": { + "name": "calendar_event_drives_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_event_drives_eventId_calendar_events_id_fk": { + "name": "calendar_event_drives_eventId_calendar_events_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "calendar_events", + "columnsFrom": [ + "eventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_event_drives_driveId_drives_id_fk": { + "name": "calendar_event_drives_driveId_drives_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_event_drives_sharedBy_users_id_fk": { + "name": "calendar_event_drives_sharedBy_users_id_fk", + "tableFrom": "calendar_event_drives", + "tableTo": "users", + "columnsFrom": [ + "sharedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_event_drives_event_drive_key": { + "name": "calendar_event_drives_event_drive_key", + "nullsNotDistinct": false, + "columns": [ + "eventId", + "driveId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "startAt": { + "name": "startAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "endAt": { + "name": "endAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "allDay": { + "name": "allDay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "recurrenceRule": { + "name": "recurrenceRule", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recurrenceExceptions": { + "name": "recurrenceExceptions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "recurringEventId": { + "name": "recurringEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "originalStartAt": { + "name": "originalStartAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "EventVisibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRIVE'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "isTrashed": { + "name": "isTrashed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trashedAt": { + "name": "trashedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "googleEventId": { + "name": "googleEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "googleCalendarId": { + "name": "googleCalendarId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "syncedFromGoogle": { + "name": "syncedFromGoogle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastGoogleSync": { + "name": "lastGoogleSync", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "googleSyncReadOnly": { + "name": "googleSyncReadOnly", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "calendar_events_drive_id_idx": { + "name": "calendar_events_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_created_by_id_idx": { + "name": "calendar_events_created_by_id_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_page_id_idx": { + "name": "calendar_events_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_start_at_idx": { + "name": "calendar_events_start_at_idx", + "columns": [ + { + "expression": "startAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_end_at_idx": { + "name": "calendar_events_end_at_idx", + "columns": [ + { + "expression": "endAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_drive_id_start_at_idx": { + "name": "calendar_events_drive_id_start_at_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_recurring_event_id_idx": { + "name": "calendar_events_recurring_event_id_idx", + "columns": [ + { + "expression": "recurringEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_is_trashed_idx": { + "name": "calendar_events_is_trashed_idx", + "columns": [ + { + "expression": "isTrashed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_google_event_id_idx": { + "name": "calendar_events_google_event_id_idx", + "columns": [ + { + "expression": "googleEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_events_synced_from_google_idx": { + "name": "calendar_events_synced_from_google_idx", + "columns": [ + { + "expression": "syncedFromGoogle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_events_driveId_drives_id_fk": { + "name": "calendar_events_driveId_drives_id_fk", + "tableFrom": "calendar_events", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_createdById_users_id_fk": { + "name": "calendar_events_createdById_users_id_fk", + "tableFrom": "calendar_events", + "tableTo": "users", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_events_pageId_pages_id_fk": { + "name": "calendar_events_pageId_pages_id_fk", + "tableFrom": "calendar_events", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_events_google_source_per_user_key": { + "name": "calendar_events_google_source_per_user_key", + "nullsNotDistinct": false, + "columns": [ + "createdById", + "googleCalendarId", + "googleEventId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_attendees": { + "name": "event_attendees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "eventId": { + "name": "eventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "AttendeeStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "responseNote": { + "name": "responseNote", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isOrganizer": { + "name": "isOrganizer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isOptional": { + "name": "isOptional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "invitedAt": { + "name": "invitedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "respondedAt": { + "name": "respondedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "event_attendees_event_id_idx": { + "name": "event_attendees_event_id_idx", + "columns": [ + { + "expression": "eventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_user_id_idx": { + "name": "event_attendees_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_status_idx": { + "name": "event_attendees_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_attendees_user_id_status_idx": { + "name": "event_attendees_user_id_status_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_attendees_eventId_calendar_events_id_fk": { + "name": "event_attendees_eventId_calendar_events_id_fk", + "tableFrom": "event_attendees", + "tableTo": "calendar_events", + "columnsFrom": [ + "eventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_attendees_userId_users_id_fk": { + "name": "event_attendees_userId_users_id_fk", + "tableFrom": "event_attendees", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_attendees_event_user_key": { + "name": "event_attendees_event_user_key", + "nullsNotDistinct": false, + "columns": [ + "eventId", + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_calendar_connections": { + "name": "google_calendar_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "googleEmail": { + "name": "googleEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "googleAccountId": { + "name": "googleAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "GoogleCalendarConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "statusMessage": { + "name": "statusMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetDriveId": { + "name": "targetDriveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selectedCalendars": { + "name": "selectedCalendars", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "syncFrequencyMinutes": { + "name": "syncFrequencyMinutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "markAsReadOnly": { + "name": "markAsReadOnly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "lastSyncAt": { + "name": "lastSyncAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastSyncError": { + "name": "lastSyncError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "syncCursor": { + "name": "syncCursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhookChannels": { + "name": "webhookChannels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "google_calendar_connections_user_id_idx": { + "name": "google_calendar_connections_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_connections_status_idx": { + "name": "google_calendar_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_connections_target_drive_id_idx": { + "name": "google_calendar_connections_target_drive_id_idx", + "columns": [ + { + "expression": "targetDriveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_calendar_connections_userId_users_id_fk": { + "name": "google_calendar_connections_userId_users_id_fk", + "tableFrom": "google_calendar_connections", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_calendar_connections_targetDriveId_drives_id_fk": { + "name": "google_calendar_connections_targetDriveId_drives_id_fk", + "tableFrom": "google_calendar_connections", + "tableTo": "drives", + "columnsFrom": [ + "targetDriveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_calendar_connections_userId_unique": { + "name": "google_calendar_connections_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_triggers": { + "name": "calendar_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calendarEventId": { + "name": "calendarEventId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduledById": { + "name": "scheduledById", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggerAt": { + "name": "triggerAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrenceDate": { + "name": "occurrenceDate", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'1970-01-01T00:00:00.000Z'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "calendar_triggers_trigger_at_idx": { + "name": "calendar_triggers_trigger_at_idx", + "columns": [ + { + "expression": "triggerAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_scheduled_by_idx": { + "name": "calendar_triggers_scheduled_by_idx", + "columns": [ + { + "expression": "scheduledById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_calendar_event_idx": { + "name": "calendar_triggers_calendar_event_idx", + "columns": [ + { + "expression": "calendarEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_triggers_workflow_id_idx": { + "name": "calendar_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_triggers_workflowId_workflows_id_fk": { + "name": "calendar_triggers_workflowId_workflows_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_calendarEventId_calendar_events_id_fk": { + "name": "calendar_triggers_calendarEventId_calendar_events_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "calendar_events", + "columnsFrom": [ + "calendarEventId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_driveId_drives_id_fk": { + "name": "calendar_triggers_driveId_drives_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "calendar_triggers_scheduledById_users_id_fk": { + "name": "calendar_triggers_scheduledById_users_id_fk", + "tableFrom": "calendar_triggers", + "tableTo": "users", + "columnsFrom": [ + "scheduledById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_triggers_event_occurrence_key": { + "name": "calendar_triggers_event_occurrence_key", + "nullsNotDistinct": false, + "columns": [ + "calendarEventId", + "occurrenceDate" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflows": { + "name": "workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentPageId": { + "name": "agentPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contextPageIds": { + "name": "contextPageIds", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "cronExpression": { + "name": "cronExpression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "triggerType": { + "name": "triggerType", + "type": "WorkflowTriggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'cron'" + }, + "eventTriggers": { + "name": "eventTriggers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "watchedFolderIds": { + "name": "watchedFolderIds", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "eventDebounceSecs": { + "name": "eventDebounceSecs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "instructionPageId": { + "name": "instructionPageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workflows_drive_id_idx": { + "name": "workflows_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_created_by_idx": { + "name": "workflows_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_agent_page_id_idx": { + "name": "workflows_agent_page_id_idx", + "columns": [ + { + "expression": "agentPageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_enabled_next_run_idx": { + "name": "workflows_enabled_next_run_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflows_enabled_trigger_type_idx": { + "name": "workflows_enabled_trigger_type_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "triggerType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflows_driveId_drives_id_fk": { + "name": "workflows_driveId_drives_id_fk", + "tableFrom": "workflows", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_createdBy_users_id_fk": { + "name": "workflows_createdBy_users_id_fk", + "tableFrom": "workflows", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_agentPageId_pages_id_fk": { + "name": "workflows_agentPageId_pages_id_fk", + "tableFrom": "workflows", + "tableTo": "pages", + "columnsFrom": [ + "agentPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflows_instructionPageId_pages_id_fk": { + "name": "workflows_instructionPageId_pages_id_fk", + "tableFrom": "workflows", + "tableTo": "pages", + "columnsFrom": [ + "instructionPageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_runs": { + "name": "workflow_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sourceTable": { + "name": "sourceTable", + "type": "WorkflowRunSourceTable", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sourceId": { + "name": "sourceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggerAt": { + "name": "triggerAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "WorkflowRunStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "durationMs": { + "name": "durationMs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversationId": { + "name": "conversationId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_runs_workflow_started_at_idx": { + "name": "workflow_runs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_source_lookup_idx": { + "name": "workflow_runs_source_lookup_idx", + "columns": [ + { + "expression": "sourceTable", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_stuck_run_idx": { + "name": "workflow_runs_stuck_run_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "startedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_runs_running_claim_idx": { + "name": "workflow_runs_running_claim_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_runs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_runs_workflowId_workflows_id_fk": { + "name": "workflow_runs_workflowId_workflows_id_fk", + "tableFrom": "workflow_runs", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_steps": { + "name": "workflow_run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "runId": { + "name": "runId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolName": { + "name": "toolName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "WorkflowRunStepStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "durationMs": { + "name": "durationMs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_steps_run_position_idx": { + "name": "workflow_run_steps_run_position_idx", + "columns": [ + { + "expression": "runId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_steps_runId_workflow_runs_id_fk": { + "name": "workflow_run_steps_runId_workflow_runs_id_fk", + "tableFrom": "workflow_run_steps", + "tableTo": "workflow_runs", + "columnsFrom": [ + "runId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_triggers": { + "name": "task_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "taskItemId": { + "name": "taskItemId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggerType": { + "name": "triggerType", + "type": "TaskTriggerType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "nextRunAt": { + "name": "nextRunAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFiredAt": { + "name": "lastFiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFireError": { + "name": "lastFireError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_triggers_workflow_id_idx": { + "name": "task_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_triggers_task_item_id_idx": { + "name": "task_triggers_task_item_id_idx", + "columns": [ + { + "expression": "taskItemId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_triggers_enabled_next_run_idx": { + "name": "task_triggers_enabled_next_run_idx", + "columns": [ + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nextRunAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_triggers_workflowId_workflows_id_fk": { + "name": "task_triggers_workflowId_workflows_id_fk", + "tableFrom": "task_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_triggers_taskItemId_task_items_id_fk": { + "name": "task_triggers_taskItemId_task_items_id_fk", + "tableFrom": "task_triggers", + "tableTo": "task_items", + "columnsFrom": [ + "taskItemId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_triggers_task_item_trigger_type_key": { + "name": "task_triggers_task_item_trigger_type_key", + "nullsNotDistinct": false, + "columns": [ + "taskItemId", + "triggerType" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "rate_limit_buckets_expires_at_idx": { + "name": "rate_limit_buckets_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_buckets_key_window_start_pk": { + "name": "rate_limit_buckets_key_window_start_pk", + "columns": [ + "key", + "window_start" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_service_tokens": { + "name": "revoked_service_tokens", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "revoked_service_tokens_expires_at_idx": { + "name": "revoked_service_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_handoff_tokens": { + "name": "auth_handoff_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_handoff_tokens_expires_at_idx": { + "name": "auth_handoff_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_handoff_tokens_kind_expires_at_idx": { + "name": "auth_handoff_tokens_kind_expires_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_handoff_tokens_token_hash_kind_pk": { + "name": "auth_handoff_tokens_token_hash_kind_pk", + "columns": [ + "token_hash", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_invites": { + "name": "pending_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "custom_role_id": { + "name": "custom_role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_invites_drive_id_idx": { + "name": "pending_invites_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_email_idx": { + "name": "pending_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_expires_at_idx": { + "name": "pending_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_custom_role_id_idx": { + "name": "pending_invites_custom_role_id_idx", + "columns": [ + { + "expression": "custom_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_invites_active_drive_email_idx": { + "name": "pending_invites_active_drive_email_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_invites_drive_id_drives_id_fk": { + "name": "pending_invites_drive_id_drives_id_fk", + "tableFrom": "pending_invites", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_invites_custom_role_id_drive_roles_id_fk": { + "name": "pending_invites_custom_role_id_drive_roles_id_fk", + "tableFrom": "pending_invites", + "tableTo": "drive_roles", + "columnsFrom": [ + "custom_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pending_invites_invited_by_users_id_fk": { + "name": "pending_invites_invited_by_users_id_fk", + "tableFrom": "pending_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_invites_token_hash_unique": { + "name": "pending_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_page_invites": { + "name": "pending_page_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_page_invites_page_id_idx": { + "name": "pending_page_invites_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_email_idx": { + "name": "pending_page_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_expires_at_idx": { + "name": "pending_page_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_page_invites_active_page_email_idx": { + "name": "pending_page_invites_active_page_email_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_page_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_page_invites_invited_by_users_id_fk": { + "name": "pending_page_invites_invited_by_users_id_fk", + "tableFrom": "pending_page_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_page_invites_page_id_pages_id_fk": { + "name": "pending_page_invites_page_id_pages_id_fk", + "tableFrom": "pending_page_invites", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_page_invites_token_hash_unique": { + "name": "pending_page_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_connection_invites": { + "name": "pending_connection_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_message": { + "name": "request_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_connection_invites_invited_by_idx": { + "name": "pending_connection_invites_invited_by_idx", + "columns": [ + { + "expression": "invited_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_email_idx": { + "name": "pending_connection_invites_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_expires_at_idx": { + "name": "pending_connection_invites_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_connection_invites_active_inviter_email_idx": { + "name": "pending_connection_invites_active_inviter_email_idx", + "columns": [ + { + "expression": "invited_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"pending_connection_invites\".\"consumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_connection_invites_invited_by_users_id_fk": { + "name": "pending_connection_invites_invited_by_users_id_fk", + "tableFrom": "pending_connection_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_connection_invites_token_hash_unique": { + "name": "pending_connection_invites_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_pending_abort_intents": { + "name": "ai_pending_abort_intents", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_pending_abort_intents_conversation_id_user_id_pk": { + "name": "ai_pending_abort_intents_conversation_id_user_id_pk", + "columns": [ + "conversation_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_stream_frames": { + "name": "ai_stream_frames", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_seq": { + "name": "from_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frame_count": { + "name": "frame_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frames": { + "name": "frames", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_stream_frames_created_at_idx": { + "name": "ai_stream_frames_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_stream_frames_conversation_id_conversations_id_fk": { + "name": "ai_stream_frames_conversation_id_conversations_id_fk", + "tableFrom": "ai_stream_frames", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ai_stream_frames_message_id_from_seq_pk": { + "name": "ai_stream_frames_message_id_from_seq_pk", + "columns": [ + "message_id", + "from_seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_stream_sessions": { + "name": "ai_stream_sessions", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Someone'" + }, + "browser_session_id": { + "name": "browser_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'streaming'" + }, + "parts": { + "name": "parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "raw_parts_count": { + "name": "raw_parts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "abort_requested_at": { + "name": "abort_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reap_claimed_at": { + "name": "reap_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ai_stream_sessions_channel_status_idx": { + "name": "ai_stream_sessions_channel_status_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_stream_sessions_conversation_status_idx": { + "name": "ai_stream_sessions_conversation_status_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_stream_sessions_user_status_idx": { + "name": "ai_stream_sessions_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_stream_sessions_stream_id_idx": { + "name": "ai_stream_sessions_stream_id_idx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_stream_sessions_conversation_id_conversations_id_fk": { + "name": "ai_stream_sessions_conversation_id_conversations_id_fk", + "tableFrom": "ai_stream_sessions", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drive_share_links": { + "name": "drive_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "MemberRole", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "custom_role_id": { + "name": "custom_role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "drive_share_links_drive_id_idx": { + "name": "drive_share_links_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_expires_at_idx": { + "name": "drive_share_links_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_is_active_idx": { + "name": "drive_share_links_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_share_links_custom_role_id_idx": { + "name": "drive_share_links_custom_role_id_idx", + "columns": [ + { + "expression": "custom_role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_share_links_drive_id_drives_id_fk": { + "name": "drive_share_links_drive_id_drives_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_share_links_custom_role_id_drive_roles_id_fk": { + "name": "drive_share_links_custom_role_id_drive_roles_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "drive_roles", + "columnsFrom": [ + "custom_role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "drive_share_links_created_by_users_id_fk": { + "name": "drive_share_links_created_by_users_id_fk", + "tableFrom": "drive_share_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "drive_share_links_token_unique": { + "name": "drive_share_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_share_links": { + "name": "page_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "page_share_links_page_id_idx": { + "name": "page_share_links_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_share_links_expires_at_idx": { + "name": "page_share_links_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_share_links_is_active_idx": { + "name": "page_share_links_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_share_links_page_id_pages_id_fk": { + "name": "page_share_links_page_id_pages_id_fk", + "tableFrom": "page_share_links", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_share_links_created_by_users_id_fk": { + "name": "page_share_links_created_by_users_id_fk", + "tableFrom": "page_share_links", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_share_links_token_unique": { + "name": "page_share_links_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.zoom_connections": { + "name": "zoom_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokenExpiresAt": { + "name": "tokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "zoomUserId": { + "name": "zoomUserId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zoomAccountId": { + "name": "zoomAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zoomEmail": { + "name": "zoomEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ZoomConnectionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "targetDriveId": { + "name": "targetDriveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetFolderId": { + "name": "targetFolderId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopeVersion": { + "name": "scopeVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "includeAiSummary": { + "name": "includeAiSummary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includeActionItems": { + "name": "includeActionItems", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "includeTranscript": { + "name": "includeTranscript", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "zoom_connections_user_id_idx": { + "name": "zoom_connections_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_status_idx": { + "name": "zoom_connections_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_account_id_idx": { + "name": "zoom_connections_account_id_idx", + "columns": [ + { + "expression": "zoomAccountId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "zoom_connections_target_drive_id_idx": { + "name": "zoom_connections_target_drive_id_idx", + "columns": [ + { + "expression": "targetDriveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "zoom_connections_userId_users_id_fk": { + "name": "zoom_connections_userId_users_id_fk", + "tableFrom": "zoom_connections", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "zoom_connections_targetDriveId_drives_id_fk": { + "name": "zoom_connections_targetDriveId_drives_id_fk", + "tableFrom": "zoom_connections", + "tableTo": "drives", + "columnsFrom": [ + "targetDriveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "zoom_connections_userId_unique": { + "name": "zoom_connections_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_triggers": { + "name": "webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflowId": { + "name": "workflowId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connectionId": { + "name": "connectionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pageWebhookId": { + "name": "pageWebhookId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "lastFiredAt": { + "name": "lastFiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFireError": { + "name": "lastFireError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "webhook_triggers_workflow_id_idx": { + "name": "webhook_triggers_workflow_id_idx", + "columns": [ + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_provider_event_idx": { + "name": "webhook_triggers_provider_event_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "eventType", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "isEnabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_connection_id_idx": { + "name": "webhook_triggers_connection_id_idx", + "columns": [ + { + "expression": "connectionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_page_webhook_id_idx": { + "name": "webhook_triggers_page_webhook_id_idx", + "columns": [ + { + "expression": "pageWebhookId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_triggers_page_webhook_workflow_unique": { + "name": "webhook_triggers_page_webhook_workflow_unique", + "columns": [ + { + "expression": "pageWebhookId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflowId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook_triggers\".\"pageWebhookId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_triggers_workflowId_workflows_id_fk": { + "name": "webhook_triggers_workflowId_workflows_id_fk", + "tableFrom": "webhook_triggers", + "tableTo": "workflows", + "columnsFrom": [ + "workflowId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_triggers_connectionId_zoom_connections_id_fk": { + "name": "webhook_triggers_connectionId_zoom_connections_id_fk", + "tableFrom": "webhook_triggers", + "tableTo": "zoom_connections", + "columnsFrom": [ + "connectionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_triggers_pageWebhookId_page_webhooks_id_fk": { + "name": "webhook_triggers_pageWebhookId_page_webhooks_id_fk", + "tableFrom": "webhook_triggers", + "tableTo": "page_webhooks", + "columnsFrom": [ + "pageWebhookId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_triggers_connection_workflow_event_unique": { + "name": "webhook_triggers_connection_workflow_event_unique", + "nullsNotDistinct": false, + "columns": [ + "connectionId", + "workflowId", + "eventType" + ] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_triggers_anchor_chk": { + "name": "webhook_triggers_anchor_chk", + "value": "(\"webhook_triggers\".\"connectionId\" IS NOT NULL AND \"webhook_triggers\".\"pageWebhookId\" IS NULL) OR (\"webhook_triggers\".\"connectionId\" IS NULL AND \"webhook_triggers\".\"pageWebhookId\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.message_drafts": { + "name": "message_drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contextKey": { + "name": "contextKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_drafts_user_context_key": { + "name": "message_drafts_user_context_key", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contextKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_drafts_expires_at_idx": { + "name": "message_drafts_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_drafts_userId_users_id_fk": { + "name": "message_drafts_userId_users_id_fk", + "tableFrom": "message_drafts", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.published_pages": { + "name": "published_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publish_title": { + "name": "publish_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_description": { + "name": "publish_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_og_image_url": { + "name": "publish_og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "noindex": { + "name": "noindex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme_bridge_enabled": { + "name": "theme_bridge_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "published_pages_drive_id_idx": { + "name": "published_pages_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_pages_page_id_idx": { + "name": "published_pages_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_pages_drive_id_drives_id_fk": { + "name": "published_pages_drive_id_drives_id_fk", + "tableFrom": "published_pages", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_pages_page_id_pages_id_fk": { + "name": "published_pages_page_id_pages_id_fk", + "tableFrom": "published_pages", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_pages_published_by_users_id_fk": { + "name": "published_pages_published_by_users_id_fk", + "tableFrom": "published_pages", + "tableTo": "users", + "columnsFrom": [ + "published_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_pages_page_id_key": { + "name": "published_pages_page_id_key", + "nullsNotDistinct": false, + "columns": [ + "page_id" + ] + }, + "published_pages_drive_id_path_key": { + "name": "published_pages_drive_id_path_key", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_balances": { + "name": "credit_balances", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthlyRemainingCents": { + "name": "monthlyRemainingCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monthlyAllowanceCents": { + "name": "monthlyAllowanceCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "topupRemainingCents": { + "name": "topupRemainingCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "debtCents": { + "name": "debtCents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pendingMillicents": { + "name": "pendingMillicents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "monthlyPeriodStart": { + "name": "monthlyPeriodStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "monthlyPeriodEnd": { + "name": "monthlyPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credit_balances_userId_users_id_fk": { + "name": "credit_balances_userId_users_id_fk", + "tableFrom": "credit_balances", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_balances_monthly_remaining_nonneg": { + "name": "credit_balances_monthly_remaining_nonneg", + "value": "\"credit_balances\".\"monthlyRemainingCents\" >= 0" + }, + "credit_balances_monthly_allowance_nonneg": { + "name": "credit_balances_monthly_allowance_nonneg", + "value": "\"credit_balances\".\"monthlyAllowanceCents\" >= 0" + }, + "credit_balances_topup_remaining_nonneg": { + "name": "credit_balances_topup_remaining_nonneg", + "value": "\"credit_balances\".\"topupRemainingCents\" >= 0" + }, + "credit_balances_debt_cents_nonneg": { + "name": "credit_balances_debt_cents_nonneg", + "value": "\"credit_balances\".\"debtCents\" >= 0" + }, + "credit_balances_pending_millicents_range": { + "name": "credit_balances_pending_millicents_range", + "value": "\"credit_balances\".\"pendingMillicents\" >= 0 AND \"credit_balances\".\"pendingMillicents\" < 1000" + }, + "credit_balances_period_order": { + "name": "credit_balances_period_order", + "value": "\"credit_balances\".\"monthlyPeriodStart\" IS NULL OR \"credit_balances\".\"monthlyPeriodEnd\" IS NULL OR \"credit_balances\".\"monthlyPeriodStart\" <= \"credit_balances\".\"monthlyPeriodEnd\"" + } + }, + "isRLSEnabled": false + }, + "public.credit_holds": { + "name": "credit_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "estCents": { + "name": "estCents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "aiUsageLogId": { + "name": "aiUsageLogId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "credit_holds_user_idx": { + "name": "credit_holds_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_holds_expires_idx": { + "name": "credit_holds_expires_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_holds_userId_users_id_fk": { + "name": "credit_holds_userId_users_id_fk", + "tableFrom": "credit_holds", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_holds_est_cents_nonneg": { + "name": "credit_holds_est_cents_nonneg", + "value": "\"credit_holds\".\"estCents\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entryType": { + "name": "entryType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amountCents": { + "name": "amountCents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "appliedCents": { + "name": "appliedCents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chargeMillicents": { + "name": "chargeMillicents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aiUsageLogId": { + "name": "aiUsageLogId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "realCostCents": { + "name": "realCostCents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "markupBps": { + "name": "markupBps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15000 + }, + "stripeRef": { + "name": "stripeRef", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumeStatus": { + "name": "consumeStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "consumeError": { + "name": "consumeError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcileGenerationKey": { + "name": "reconcileGenerationKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credit_ledger_user_idx": { + "name": "credit_ledger_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_usage_log_unique": { + "name": "credit_ledger_usage_log_unique", + "columns": [ + { + "expression": "aiUsageLogId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"aiUsageLogId\" IS NOT NULL AND \"credit_ledger\".\"entryType\" = 'usage'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_stripe_ref_unique": { + "name": "credit_ledger_stripe_ref_unique", + "columns": [ + { + "expression": "stripeRef", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"stripeRef\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_reconcile_key_unique": { + "name": "credit_ledger_reconcile_key_unique", + "columns": [ + { + "expression": "reconcileGenerationKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_ledger\".\"reconcileGenerationKey\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credit_ledger_consume_status_idx": { + "name": "credit_ledger_consume_status_idx", + "columns": [ + { + "expression": "consumeStatus", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_userId_users_id_fk": { + "name": "credit_ledger_userId_users_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.commands": { + "name": "commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_page_id": { + "name": "entry_page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'document'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "commands_user_id_idx": { + "name": "commands_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "commands_drive_id_idx": { + "name": "commands_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "commands_entry_page_id_idx": { + "name": "commands_entry_page_id_idx", + "columns": [ + { + "expression": "entry_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "commands_user_id_users_id_fk": { + "name": "commands_user_id_users_id_fk", + "tableFrom": "commands", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "commands_drive_id_drives_id_fk": { + "name": "commands_drive_id_drives_id_fk", + "tableFrom": "commands", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "commands_created_by_id_users_id_fk": { + "name": "commands_created_by_id_users_id_fk", + "tableFrom": "commands", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "commands_entry_page_id_pages_id_fk": { + "name": "commands_entry_page_id_pages_id_fk", + "tableFrom": "commands", + "tableTo": "pages", + "columnsFrom": [ + "entry_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "commands_user_trigger": { + "name": "commands_user_trigger", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "trigger" + ] + }, + "commands_drive_trigger": { + "name": "commands_drive_trigger", + "nullsNotDistinct": false, + "columns": [ + "drive_id", + "trigger" + ] + } + }, + "policies": {}, + "checkConstraints": { + "commands_scope_chk": { + "name": "commands_scope_chk", + "value": "(\"commands\".\"user_id\" IS NOT NULL AND \"commands\".\"drive_id\" IS NULL) OR (\"commands\".\"user_id\" IS NULL AND \"commands\".\"drive_id\" IS NOT NULL)" + }, + "commands_type_chk": { + "name": "commands_type_chk", + "value": "\"commands\".\"type\" IN ('document', 'prompt_template', 'builtin')" + } + }, + "isRLSEnabled": false + }, + "public.content_tags": { + "name": "content_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "targetKind": { + "name": "targetKind", + "type": "ContentTagTargetKind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchorStatus": { + "name": "anchorStatus", + "type": "ContentTagAnchorStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "channelMessageId": { + "name": "channelMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aiMessageId": { + "name": "aiMessageId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "ContentTagSource", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "content_tags_page_id_idx": { + "name": "content_tags_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_tags_tag_id_idx": { + "name": "content_tags_tag_id_idx", + "columns": [ + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_tags_created_by_idx": { + "name": "content_tags_created_by_idx", + "columns": [ + { + "expression": "createdBy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_tags_page_target_unique": { + "name": "content_tags_page_target_unique", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"content_tags\".\"targetKind\" = 'page'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_tags_channel_message_target_unique": { + "name": "content_tags_channel_message_target_unique", + "columns": [ + { + "expression": "channelMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"content_tags\".\"targetKind\" = 'channel_message'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "content_tags_ai_message_target_unique": { + "name": "content_tags_ai_message_target_unique", + "columns": [ + { + "expression": "aiMessageId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tagId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"content_tags\".\"targetKind\" = 'ai_message'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_tags_tagId_tags_id_fk": { + "name": "content_tags_tagId_tags_id_fk", + "tableFrom": "content_tags", + "tableTo": "tags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_tags_pageId_pages_id_fk": { + "name": "content_tags_pageId_pages_id_fk", + "tableFrom": "content_tags", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_tags_channelMessageId_channel_messages_id_fk": { + "name": "content_tags_channelMessageId_channel_messages_id_fk", + "tableFrom": "content_tags", + "tableTo": "channel_messages", + "columnsFrom": [ + "channelMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_tags_aiMessageId_messages_id_fk": { + "name": "content_tags_aiMessageId_messages_id_fk", + "tableFrom": "content_tags", + "tableTo": "messages", + "columnsFrom": [ + "aiMessageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_tags_createdBy_users_id_fk": { + "name": "content_tags_createdBy_users_id_fk", + "tableFrom": "content_tags", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "content_tags_target_chk": { + "name": "content_tags_target_chk", + "value": "(\n (\"content_tags\".\"targetKind\" = 'page' AND \"content_tags\".\"anchor\" IS NULL AND \"content_tags\".\"anchorStatus\" IS NULL AND \"content_tags\".\"channelMessageId\" IS NULL AND \"content_tags\".\"aiMessageId\" IS NULL)\n OR (\"content_tags\".\"targetKind\" = 'text' AND \"content_tags\".\"anchor\" IS NOT NULL AND \"content_tags\".\"anchorStatus\" IS NOT NULL AND \"content_tags\".\"channelMessageId\" IS NULL AND \"content_tags\".\"aiMessageId\" IS NULL)\n OR (\"content_tags\".\"targetKind\" = 'sheet_cell' AND \"content_tags\".\"anchor\" IS NOT NULL AND \"content_tags\".\"anchorStatus\" IS NULL AND \"content_tags\".\"channelMessageId\" IS NULL AND \"content_tags\".\"aiMessageId\" IS NULL)\n OR (\"content_tags\".\"targetKind\" = 'channel_message' AND \"content_tags\".\"anchor\" IS NULL AND \"content_tags\".\"anchorStatus\" IS NULL AND \"content_tags\".\"channelMessageId\" IS NOT NULL AND \"content_tags\".\"aiMessageId\" IS NULL)\n OR (\"content_tags\".\"targetKind\" = 'ai_message' AND \"content_tags\".\"anchor\" IS NULL AND \"content_tags\".\"anchorStatus\" IS NULL AND \"content_tags\".\"channelMessageId\" IS NULL AND \"content_tags\".\"aiMessageId\" IS NOT NULL)\n )" + }, + "content_tags_confidence_range_chk": { + "name": "content_tags_confidence_range_chk", + "value": "\"content_tags\".\"confidence\" IS NULL OR (\"content_tags\".\"confidence\" >= 0 AND \"content_tags\".\"confidence\" <= 1)" + } + }, + "isRLSEnabled": false + }, + "public.conversation_compactions": { + "name": "conversation_compactions", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "summary_tokens": { + "name": "summary_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compacted_up_to_message_id": { + "name": "compacted_up_to_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compacted_up_to_created_at": { + "name": "compacted_up_to_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "summary_version": { + "name": "summary_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "summarizer_model": { + "name": "summarizer_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_compacted_at": { + "name": "last_compacted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversation_compactions_page_id_idx": { + "name": "conversation_compactions_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "conversation_compactions_conversation_id_source_pk": { + "name": "conversation_compactions_conversation_id_source_pk", + "columns": [ + "conversation_id", + "source" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "conversation_compactions_source_chk": { + "name": "conversation_compactions_source_chk", + "value": "\"conversation_compactions\".\"source\" IN ('page', 'global')" + } + }, + "isRLSEnabled": false + }, + "public.custom_domains": { + "name": "custom_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "custom_domain_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "platform_owned": { + "name": "platform_owned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publish_landing_page_id": { + "name": "publish_landing_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publish_not_found_page_id": { + "name": "publish_not_found_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_domains_hostname_key": { + "name": "custom_domains_hostname_key", + "columns": [ + { + "expression": "hostname", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_domains_drive_id_idx": { + "name": "custom_domains_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_domains_primary_per_drive": { + "name": "custom_domains_primary_per_drive", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"custom_domains\".\"is_primary\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_domains_drive_id_drives_id_fk": { + "name": "custom_domains_drive_id_drives_id_fk", + "tableFrom": "custom_domains", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_domains_publish_landing_page_id_pages_id_fk": { + "name": "custom_domains_publish_landing_page_id_pages_id_fk", + "tableFrom": "custom_domains", + "tableTo": "pages", + "columnsFrom": [ + "publish_landing_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_domains_publish_not_found_page_id_pages_id_fk": { + "name": "custom_domains_publish_not_found_page_id_pages_id_fk", + "tableFrom": "custom_domains", + "tableTo": "pages", + "columnsFrom": [ + "publish_not_found_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.incidents": { + "name": "incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'detected'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detectedAt": { + "name": "detectedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reportedBy": { + "name": "reportedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "affectedUserCount": { + "name": "affectedUserCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "affectedScope": { + "name": "affectedScope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "riskLevel": { + "name": "riskLevel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requiresAuthorityNotification": { + "name": "requiresAuthorityNotification", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "authorityNotificationDeadline": { + "name": "authorityNotificationDeadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authorityNotifiedAt": { + "name": "authorityNotifiedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requiresSubjectNotification": { + "name": "requiresSubjectNotification", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subjectsNotifiedAt": { + "name": "subjectsNotifiedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closedAt": { + "name": "closedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_incidents_status": { + "name": "idx_incidents_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_incidents_detected_at": { + "name": "idx_incidents_detected_at", + "columns": [ + { + "expression": "detectedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_incidents_authority_deadline": { + "name": "idx_incidents_authority_deadline", + "columns": [ + { + "expression": "authorityNotificationDeadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "incidents_reportedBy_users_id_fk": { + "name": "incidents_reportedBy_users_id_fk", + "tableFrom": "incidents", + "tableTo": "users", + "columnsFrom": [ + "reportedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_subject_requests": { + "name": "data_subject_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "data_subject_request_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'erasure'" + }, + "status": { + "name": "status", + "type": "data_subject_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "force_delete": { + "name": "force_delete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_type": { + "name": "requested_by_type", + "type": "data_subject_requester_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'self'" + }, + "legal_basis": { + "name": "legal_basis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sla_deadline": { + "name": "sla_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step_results": { + "name": "step_results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_subject_requests_status_idx": { + "name": "data_subject_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_subject_requests_sla_deadline_idx": { + "name": "data_subject_requests_sla_deadline_idx", + "columns": [ + { + "expression": "sla_deadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_subject_requests_user_id_idx": { + "name": "data_subject_requests_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_subject_requests_user_id_users_id_fk": { + "name": "data_subject_requests_user_id_users_id_fk", + "tableFrom": "data_subject_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "data_subject_requests_requested_by_user_id_users_id_fk": { + "name": "data_subject_requests_requested_by_user_id_users_id_fk", + "tableFrom": "data_subject_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_access_tokens_family_id_idx": { + "name": "oauth_access_tokens_family_id_idx", + "columns": [ + { + "expression": "familyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_user_id_idx": { + "name": "oauth_access_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_client_id_idx": { + "name": "oauth_access_tokens_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_token_hash_idx": { + "name": "oauth_access_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_tokens_expires_at_idx": { + "name": "oauth_access_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_tokens_clientId_oauth_clients_id_fk": { + "name": "oauth_access_tokens_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_userId_users_id_fk": { + "name": "oauth_access_tokens_userId_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_tokenHash_unique": { + "name": "oauth_access_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codePrefix": { + "name": "codePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallengeMethod": { + "name": "codeChallengeMethod", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "issuedFamilyId": { + "name": "issuedFamilyId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_authorization_codes_client_id_idx": { + "name": "oauth_authorization_codes_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_user_id_idx": { + "name": "oauth_authorization_codes_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_code_hash_idx": { + "name": "oauth_authorization_codes_code_hash_idx", + "columns": [ + { + "expression": "codeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_expires_at_idx": { + "name": "oauth_authorization_codes_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_codes_clientId_oauth_clients_id_fk": { + "name": "oauth_authorization_codes_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_codes_userId_users_id_fk": { + "name": "oauth_authorization_codes_userId_users_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_authorization_codes_codeHash_unique": { + "name": "oauth_authorization_codes_codeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "codeHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientType": { + "name": "clientType", + "type": "OAuthClientType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "redirectUris": { + "name": "redirectUris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "isFirstParty": { + "name": "isFirstParty", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "disabledAt": { + "name": "disabledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_clients_client_id_idx": { + "name": "oauth_clients_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_clientId_unique": { + "name": "oauth_clients_clientId_unique", + "nullsNotDistinct": false, + "columns": [ + "clientId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_device_codes": { + "name": "oauth_device_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deviceCodeHash": { + "name": "deviceCodeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deviceCodePrefix": { + "name": "deviceCodePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userCodeHash": { + "name": "userCodeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userCodePrefix": { + "name": "userCodePrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "approvedAt": { + "name": "approvedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deniedAt": { + "name": "deniedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "redeemedAt": { + "name": "redeemedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastPolledAt": { + "name": "lastPolledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pollIntervalSeconds": { + "name": "pollIntervalSeconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_device_codes_client_id_idx": { + "name": "oauth_device_codes_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_user_id_idx": { + "name": "oauth_device_codes_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_device_code_hash_idx": { + "name": "oauth_device_codes_device_code_hash_idx", + "columns": [ + { + "expression": "deviceCodeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_user_code_hash_idx": { + "name": "oauth_device_codes_user_code_hash_idx", + "columns": [ + { + "expression": "userCodeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_device_codes_expires_at_idx": { + "name": "oauth_device_codes_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_device_codes_clientId_oauth_clients_id_fk": { + "name": "oauth_device_codes_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_device_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_device_codes_userId_users_id_fk": { + "name": "oauth_device_codes_userId_users_id_fk", + "tableFrom": "oauth_device_codes", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_device_codes_deviceCodeHash_unique": { + "name": "oauth_device_codes_deviceCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "deviceCodeHash" + ] + }, + "oauth_device_codes_userCodeHash_unique": { + "name": "oauth_device_codes_userCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "userCodeHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenPrefix": { + "name": "tokenPrefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "familyExpiresAt": { + "name": "familyExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "replacedByTokenId": { + "name": "replacedByTokenId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revokedReason": { + "name": "revokedReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_refresh_tokens_family_id_idx": { + "name": "oauth_refresh_tokens_family_id_idx", + "columns": [ + { + "expression": "familyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_user_id_idx": { + "name": "oauth_refresh_tokens_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_client_id_idx": { + "name": "oauth_refresh_tokens_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_token_hash_idx": { + "name": "oauth_refresh_tokens_token_hash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_expires_at_idx": { + "name": "oauth_refresh_tokens_expires_at_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_clientId_oauth_clients_id_fk": { + "name": "oauth_refresh_tokens_clientId_oauth_clients_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_userId_users_id_fk": { + "name": "oauth_refresh_tokens_userId_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_tokenHash_unique": { + "name": "oauth_refresh_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_targets": { + "name": "form_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "drive_id": { + "name": "drive_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sheet:append'" + }, + "canvas_page_id": { + "name": "canvas_page_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "header_row": { + "name": "header_row", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "next_row": { + "name": "next_row", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_email": { + "name": "notification_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_submitted_at": { + "name": "last_submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "submission_count": { + "name": "submission_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "form_targets_page_id_idx": { + "name": "form_targets_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_drive_id_idx": { + "name": "form_targets_drive_id_idx", + "columns": [ + { + "expression": "drive_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_status_idx": { + "name": "form_targets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_canvas_page_id_idx": { + "name": "form_targets_canvas_page_id_idx", + "columns": [ + { + "expression": "canvas_page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_targets_one_active_per_page_idx": { + "name": "form_targets_one_active_per_page_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form_targets\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_targets_drive_id_drives_id_fk": { + "name": "form_targets_drive_id_drives_id_fk", + "tableFrom": "form_targets", + "tableTo": "drives", + "columnsFrom": [ + "drive_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_targets_page_id_pages_id_fk": { + "name": "form_targets_page_id_pages_id_fk", + "tableFrom": "form_targets", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_targets_canvas_page_id_pages_id_fk": { + "name": "form_targets_canvas_page_id_pages_id_fk", + "tableFrom": "form_targets", + "tableTo": "pages", + "columnsFrom": [ + "canvas_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_targets_created_by_users_id_fk": { + "name": "form_targets_created_by_users_id_fk", + "tableFrom": "form_targets", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "form_targets_token_hash_unique": { + "name": "form_targets_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine_sprite_reclaims": { + "name": "machine_sprite_reclaims", + "schema": "", + "columns": { + "sandboxId": { + "name": "sandboxId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "spriteInstanceId": { + "name": "spriteInstanceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recordedAt": { + "name": "recordedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastAttemptAt": { + "name": "lastAttemptAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_sprite_reclaims_recorded_at_idx": { + "name": "machine_sprite_reclaims_recorded_at_idx", + "columns": [ + { + "expression": "recordedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_deploy_token_mints": { + "name": "app_deploy_token_mints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publishedAppId": { + "name": "publishedAppId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flyAppName": { + "name": "flyAppName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mintedAt": { + "name": "mintedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "settledAt": { + "name": "settledAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expiry": { + "name": "expiry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "app_deploy_token_mints_app_idx": { + "name": "app_deploy_token_mints_app_idx", + "columns": [ + { + "expression": "publishedAppId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mintedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_deploy_token_mints_publishedAppId_published_apps_id_fk": { + "name": "app_deploy_token_mints_publishedAppId_published_apps_id_fk", + "tableFrom": "app_deploy_token_mints", + "tableTo": "published_apps", + "columnsFrom": [ + "publishedAppId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_deploy_token_mints_expiry_nonempty": { + "name": "app_deploy_token_mints_expiry_nonempty", + "value": "length(\"app_deploy_token_mints\".\"expiry\") > 0" + }, + "app_deploy_token_mints_purpose_nonempty": { + "name": "app_deploy_token_mints_purpose_nonempty", + "value": "length(\"app_deploy_token_mints\".\"purpose\") > 0" + }, + "app_deploy_token_mints_outcome_allowed": { + "name": "app_deploy_token_mints_outcome_allowed", + "value": "\"app_deploy_token_mints\".\"outcome\" IN ('pending', 'minted', 'failed')" + }, + "app_deploy_token_mints_settled_coherent": { + "name": "app_deploy_token_mints_settled_coherent", + "value": "(\"app_deploy_token_mints\".\"outcome\" = 'pending') = (\"app_deploy_token_mints\".\"settledAt\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.app_hosting_reclaims": { + "name": "app_hosting_reclaims", + "schema": "", + "columns": { + "flyAppName": { + "name": "flyAppName", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publishedAppId": { + "name": "publishedAppId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recordedAt": { + "name": "recordedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lastAttemptAt": { + "name": "lastAttemptAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "app_hosting_reclaims_recorded_at_idx": { + "name": "app_hosting_reclaims_recorded_at_idx", + "columns": [ + { + "expression": "recordedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_hosting_reclaims_attempts_nonneg": { + "name": "app_hosting_reclaims_attempts_nonneg", + "value": "\"app_hosting_reclaims\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.published_app_machine_events": { + "name": "published_app_machine_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publishedAppId": { + "name": "publishedAppId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flyAppName": { + "name": "flyAppName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flyEventId": { + "name": "flyEventId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flyEventType": { + "name": "flyEventType", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flyEventStatus": { + "name": "flyEventStatus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurredAt": { + "name": "occurredAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "recordedAt": { + "name": "recordedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "published_app_machine_events_app_idx": { + "name": "published_app_machine_events_app_idx", + "columns": [ + { + "expression": "publishedAppId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurredAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_app_machine_events_fly_event_unique": { + "name": "published_app_machine_events_fly_event_unique", + "columns": [ + { + "expression": "machineId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "flyEventId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"flyEventId\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_app_machine_events_publishedAppId_published_apps_id_fk": { + "name": "published_app_machine_events_publishedAppId_published_apps_id_fk", + "tableFrom": "published_app_machine_events", + "tableTo": "published_apps", + "columnsFrom": [ + "publishedAppId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "published_app_machine_events_origin_allowed": { + "name": "published_app_machine_events_origin_allowed", + "value": "\"published_app_machine_events\".\"origin\" IN ('orchestrator', 'fly')" + }, + "published_app_machine_events_action_allowed": { + "name": "published_app_machine_events_action_allowed", + "value": "\"published_app_machine_events\".\"action\" IN ('start', 'stop')" + }, + "published_app_machine_events_fly_event_id_coherent": { + "name": "published_app_machine_events_fly_event_id_coherent", + "value": "(\"published_app_machine_events\".\"origin\" = 'fly') = (\"published_app_machine_events\".\"flyEventId\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.published_apps": { + "name": "published_apps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "envId": { + "name": "envId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flyAppName": { + "name": "flyAppName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "networkName": { + "name": "networkName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "published_app_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "guestPreset": { + "name": "guestPreset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'shared-cpu-1x-512'" + }, + "imageDigest": { + "name": "imageDigest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageSizeBytes": { + "name": "imageSizeBytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "imageSizeMeasuredAt": { + "name": "imageSizeMeasuredAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "published_app_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metered'" + }, + "machineId": { + "name": "machineId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastWakeAt": { + "name": "lastWakeAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastStopAt": { + "name": "lastStopAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastHitAt": { + "name": "lastHitAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "awakeSecondsDay": { + "name": "awakeSecondsDay", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "awakeSecondsToday": { + "name": "awakeSecondsToday", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "awakeBilledThrough": { + "name": "awakeBilledThrough", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "awakeHoldId": { + "name": "awakeHoldId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storageLastBilledAt": { + "name": "storageLastBilledAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastError": { + "name": "lastError", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimedAt": { + "name": "claimedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimedBy": { + "name": "claimedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "published_apps_drive_idx": { + "name": "published_apps_drive_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_apps_owner_idx": { + "name": "published_apps_owner_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_apps_status_idx": { + "name": "published_apps_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updatedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_apps_idle_idx": { + "name": "published_apps_idle_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lastHitAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_apps_envId_drive_envs_id_fk": { + "name": "published_apps_envId_drive_envs_id_fk", + "tableFrom": "published_apps", + "tableTo": "drive_envs", + "columnsFrom": [ + "envId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_apps_driveId_drives_id_fk": { + "name": "published_apps_driveId_drives_id_fk", + "tableFrom": "published_apps", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_apps_ownerId_users_id_fk": { + "name": "published_apps_ownerId_users_id_fk", + "tableFrom": "published_apps", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_apps_envId_unique": { + "name": "published_apps_envId_unique", + "nullsNotDistinct": false, + "columns": [ + "envId" + ] + }, + "published_apps_flyAppName_unique": { + "name": "published_apps_flyAppName_unique", + "nullsNotDistinct": false, + "columns": [ + "flyAppName" + ] + }, + "published_apps_subdomain_unique": { + "name": "published_apps_subdomain_unique", + "nullsNotDistinct": false, + "columns": [ + "subdomain" + ] + } + }, + "policies": {}, + "checkConstraints": { + "published_apps_serving_requires_image": { + "name": "published_apps_serving_requires_image", + "value": "\"published_apps\".\"status\" NOT IN ('running', 'deploying') OR \"published_apps\".\"imageDigest\" IS NOT NULL" + }, + "published_apps_running_requires_machine": { + "name": "published_apps_running_requires_machine", + "value": "\"published_apps\".\"status\" <> 'running' OR \"published_apps\".\"machineId\" IS NOT NULL" + }, + "published_apps_parked_is_metered_only": { + "name": "published_apps_parked_is_metered_only", + "value": "\"published_apps\".\"status\" <> 'parked' OR \"published_apps\".\"tier\" = 'metered'" + }, + "published_apps_guest_preset_allowed": { + "name": "published_apps_guest_preset_allowed", + "value": "\"published_apps\".\"guestPreset\" IN ('shared-cpu-1x-512', 'shared-cpu-1x-1024', 'shared-cpu-2x-2048', 'shared-cpu-4x-4096')" + }, + "published_apps_metered_guest_preset": { + "name": "published_apps_metered_guest_preset", + "value": "\"published_apps\".\"tier\" <> 'metered' OR \"published_apps\".\"guestPreset\" = 'shared-cpu-1x-512'" + }, + "published_apps_image_size_nonneg": { + "name": "published_apps_image_size_nonneg", + "value": "\"published_apps\".\"imageSizeBytes\" IS NULL OR \"published_apps\".\"imageSizeBytes\" >= 0" + }, + "published_apps_subdomain_nonempty": { + "name": "published_apps_subdomain_nonempty", + "value": "length(\"published_apps\".\"subdomain\") > 0" + }, + "published_apps_claim_coherent": { + "name": "published_apps_claim_coherent", + "value": "(\"published_apps\".\"claimedAt\" IS NULL) = (\"published_apps\".\"claimedBy\" IS NULL)" + }, + "published_apps_image_size_measured_coherent": { + "name": "published_apps_image_size_measured_coherent", + "value": "(\"published_apps\".\"imageSizeBytes\" IS NULL) = (\"published_apps\".\"imageSizeMeasuredAt\" IS NULL)" + }, + "published_apps_awake_seconds_today_nonneg": { + "name": "published_apps_awake_seconds_today_nonneg", + "value": "\"published_apps\".\"awakeSecondsToday\" >= 0" + }, + "published_apps_awake_counter_needs_day": { + "name": "published_apps_awake_counter_needs_day", + "value": "\"published_apps\".\"awakeSecondsDay\" IS NOT NULL OR \"published_apps\".\"awakeSecondsToday\" = 0" + }, + "published_apps_awake_window_needs_wake": { + "name": "published_apps_awake_window_needs_wake", + "value": "\"published_apps\".\"awakeBilledThrough\" IS NULL OR \"published_apps\".\"lastWakeAt\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.published_app_subscriptions": { + "name": "published_app_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publishedAppId": { + "name": "publishedAppId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guestPreset": { + "name": "guestPreset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripeEventCreated": { + "name": "stripeEventCreated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "published_app_subscriptions_user_idx": { + "name": "published_app_subscriptions_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "published_app_subscriptions_stripe_subscription_idx": { + "name": "published_app_subscriptions_stripe_subscription_idx", + "columns": [ + { + "expression": "stripeSubscriptionId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "published_app_subscriptions_publishedAppId_published_apps_id_fk": { + "name": "published_app_subscriptions_publishedAppId_published_apps_id_fk", + "tableFrom": "published_app_subscriptions", + "tableTo": "published_apps", + "columnsFrom": [ + "publishedAppId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "published_app_subscriptions_userId_users_id_fk": { + "name": "published_app_subscriptions_userId_users_id_fk", + "tableFrom": "published_app_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "published_app_subscriptions_publishedAppId_unique": { + "name": "published_app_subscriptions_publishedAppId_unique", + "nullsNotDistinct": false, + "columns": [ + "publishedAppId" + ] + }, + "published_app_subscriptions_stripeSubscriptionId_unique": { + "name": "published_app_subscriptions_stripeSubscriptionId_unique", + "nullsNotDistinct": false, + "columns": [ + "stripeSubscriptionId" + ] + } + }, + "policies": {}, + "checkConstraints": { + "published_app_subscriptions_status_nonempty": { + "name": "published_app_subscriptions_status_nonempty", + "value": "length(\"published_app_subscriptions\".\"status\") > 0" + }, + "published_app_subscriptions_period_ordered": { + "name": "published_app_subscriptions_period_ordered", + "value": "\"published_app_subscriptions\".\"currentPeriodEnd\" >= \"published_app_subscriptions\".\"currentPeriodStart\"" + } + }, + "isRLSEnabled": false + }, + "public.broadcast_recipients": { + "name": "broadcast_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "broadcast_id": { + "name": "broadcast_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "broadcast_recipient_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "broadcast_recipients_broadcast_user_unique": { + "name": "broadcast_recipients_broadcast_user_unique", + "columns": [ + { + "expression": "broadcast_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "broadcast_recipients_broadcast_status_idx": { + "name": "broadcast_recipients_broadcast_status_idx", + "columns": [ + { + "expression": "broadcast_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "broadcast_recipients_broadcast_id_email_broadcasts_id_fk": { + "name": "broadcast_recipients_broadcast_id_email_broadcasts_id_fk", + "tableFrom": "broadcast_recipients", + "tableTo": "email_broadcasts", + "columnsFrom": [ + "broadcast_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "broadcast_recipients_user_id_users_id_fk": { + "name": "broadcast_recipients_user_id_users_id_fk", + "tableFrom": "broadcast_recipients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcast_templates": { + "name": "broadcast_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "broadcast_templates_active_idx": { + "name": "broadcast_templates_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "broadcast_templates_created_by_user_id_users_id_fk": { + "name": "broadcast_templates_created_by_user_id_users_id_fk", + "tableFrom": "broadcast_templates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_broadcasts": { + "name": "email_broadcasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "engine": { + "name": "engine", + "type": "email_broadcast_engine", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'transactional'" + }, + "content_mode": { + "name": "content_mode", + "type": "email_broadcast_content_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'compose'" + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_type": { + "name": "notification_type", + "type": "NotificationType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PRODUCT_UPDATE'" + }, + "audience_definition": { + "name": "audience_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "email_broadcast_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "send_limit": { + "name": "send_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "delay_ms": { + "name": "delay_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 120 + }, + "total_targeted": { + "name": "total_targeted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_count": { + "name": "sent_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "step_results": { + "name": "step_results", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_broadcasts_status_idx": { + "name": "email_broadcasts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_broadcasts_created_at_idx": { + "name": "email_broadcasts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_broadcasts_created_by_idx": { + "name": "email_broadcasts_created_by_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_broadcasts_template_id_broadcast_templates_id_fk": { + "name": "email_broadcasts_template_id_broadcast_templates_id_fk", + "tableFrom": "email_broadcasts", + "tableTo": "broadcast_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "email_broadcasts_created_by_user_id_users_id_fk": { + "name": "email_broadcasts_created_by_user_id_users_id_fk", + "tableFrom": "email_broadcasts", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_webhooks": { + "name": "page_webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pageId": { + "name": "pageId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhookToken": { + "name": "webhookToken", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhookSecretEncrypted": { + "name": "webhookSecretEncrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isEnabled": { + "name": "isEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lastFiredAt": { + "name": "lastFiredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastFireError": { + "name": "lastFireError", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_webhooks_page_id_idx": { + "name": "page_webhooks_page_id_idx", + "columns": [ + { + "expression": "pageId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_webhooks_token_idx": { + "name": "page_webhooks_token_idx", + "columns": [ + { + "expression": "webhookToken", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_webhooks_pageId_pages_id_fk": { + "name": "page_webhooks_pageId_pages_id_fk", + "tableFrom": "page_webhooks", + "tableTo": "pages", + "columnsFrom": [ + "pageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_webhooks_createdBy_users_id_fk": { + "name": "page_webhooks_createdBy_users_id_fk", + "tableFrom": "page_webhooks", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_webhooks_webhookToken_unique": { + "name": "page_webhooks_webhookToken_unique", + "nullsNotDistinct": false, + "columns": [ + "webhookToken" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_workspace_shells": { + "name": "agent_workspace_shells", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agentType": { + "name": "agentType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spriteExecId": { + "name": "spriteExecId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coldTail": { + "name": "coldTail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coldTailAt": { + "name": "coldTailAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "coldTailHasOutput": { + "name": "coldTailHasOutput", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_workspace_shells_workspace_id_idx": { + "name": "agent_workspace_shells_workspace_id_idx", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspace_shells_workspace_name_idx": { + "name": "agent_workspace_shells_workspace_name_idx", + "columns": [ + { + "expression": "workspaceId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_workspace_shells_workspaceId_agent_workspaces_id_fk": { + "name": "agent_workspace_shells_workspaceId_agent_workspaces_id_fk", + "tableFrom": "agent_workspace_shells", + "tableTo": "agent_workspaces", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_workspace_shells_ownerId_users_id_fk": { + "name": "agent_workspace_shells_ownerId_users_id_fk", + "tableFrom": "agent_workspace_shells", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_workspaces": { + "name": "agent_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "envId": { + "name": "envId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spriteKey": { + "name": "spriteKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandboxId": { + "name": "sandboxId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spriteInstanceId": { + "name": "spriteInstanceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "egressPolicyToken": { + "name": "egressPolicyToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teardownRequestedAt": { + "name": "teardownRequestedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "spriteTornDownAt": { + "name": "spriteTornDownAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "storageLastBilledAt": { + "name": "storageLastBilledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "storageMeasuredBytes": { + "name": "storageMeasuredBytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "storageMeasuredAt": { + "name": "storageMeasuredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_workspaces_drive_id_idx": { + "name": "agent_workspaces_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspaces_owner_id_idx": { + "name": "agent_workspaces_owner_id_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspaces_live_sprite_idx": { + "name": "agent_workspaces_live_sprite_idx", + "columns": [ + { + "expression": "sandboxId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spriteTornDownAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_workspaces\".\"sandboxId\" IS NOT NULL AND \"agent_workspaces\".\"spriteTornDownAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspaces_env_id_idx": { + "name": "agent_workspaces_env_id_idx", + "columns": [ + { + "expression": "envId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_workspaces_driveId_drives_id_fk": { + "name": "agent_workspaces_driveId_drives_id_fk", + "tableFrom": "agent_workspaces", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_workspaces_ownerId_users_id_fk": { + "name": "agent_workspaces_ownerId_users_id_fk", + "tableFrom": "agent_workspaces", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_workspaces_envId_drive_envs_id_fk": { + "name": "agent_workspaces_envId_drive_envs_id_fk", + "tableFrom": "agent_workspaces", + "tableTo": "drive_envs", + "columnsFrom": [ + "envId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_workspaces_env_no_sprite_check": { + "name": "agent_workspaces_env_no_sprite_check", + "value": "\"agent_workspaces\".\"envId\" IS NULL OR (\"agent_workspaces\".\"sandboxId\" IS NULL AND \"agent_workspaces\".\"spriteKey\" IS NULL AND \"agent_workspaces\".\"spriteInstanceId\" IS NULL)" + }, + "agent_workspaces_env_needs_drive_check": { + "name": "agent_workspaces_env_needs_drive_check", + "value": "\"agent_workspaces\".\"envId\" IS NULL OR \"agent_workspaces\".\"driveId\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.agent_workspace_node_revs": { + "name": "agent_workspace_node_revs", + "schema": "", + "columns": { + "rootId": { + "name": "rootId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rev": { + "name": "rev", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "agent_workspace_node_revs_rootId_agent_workspaces_id_fk": { + "name": "agent_workspace_node_revs_rootId_agent_workspaces_id_fk", + "tableFrom": "agent_workspace_node_revs", + "tableTo": "agent_workspaces", + "columnsFrom": [ + "rootId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_workspace_nodes": { + "name": "agent_workspace_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rootId": { + "name": "rootId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "nodeType": { + "name": "nodeType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "axis": { + "name": "axis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fraction": { + "name": "fraction", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "targetKind": { + "name": "targetKind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "targetId": { + "name": "targetId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_workspace_nodes_one_root_idx": { + "name": "agent_workspace_nodes_one_root_idx", + "columns": [ + { + "expression": "rootId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_workspace_nodes\".\"nodeType\" = 'root'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspace_nodes_chat_target_idx": { + "name": "agent_workspace_nodes_chat_target_idx", + "columns": [ + { + "expression": "targetId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_workspace_nodes\".\"targetKind\" = 'chat'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_workspace_nodes_parent_idx": { + "name": "agent_workspace_nodes_parent_idx", + "columns": [ + { + "expression": "rootId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parentId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_workspace_nodes_rootId_agent_workspaces_id_fk": { + "name": "agent_workspace_nodes_rootId_agent_workspaces_id_fk", + "tableFrom": "agent_workspace_nodes", + "tableTo": "agent_workspaces", + "columnsFrom": [ + "rootId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_workspace_nodes_rootId_parentId_agent_workspace_nodes_rootId_id_fk": { + "name": "agent_workspace_nodes_rootId_parentId_agent_workspace_nodes_rootId_id_fk", + "tableFrom": "agent_workspace_nodes", + "tableTo": "agent_workspace_nodes", + "columnsFrom": [ + "rootId", + "parentId" + ], + "columnsTo": [ + "rootId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_workspace_nodes_rootId_id_pk": { + "name": "agent_workspace_nodes_rootId_id_pk", + "columns": [ + "rootId", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_workspace_nodes_root_no_parent_chk": { + "name": "agent_workspace_nodes_root_no_parent_chk", + "value": "(\"agent_workspace_nodes\".\"nodeType\" = 'root') = (\"agent_workspace_nodes\".\"parentId\" IS NULL)" + }, + "agent_workspace_nodes_node_type_chk": { + "name": "agent_workspace_nodes_node_type_chk", + "value": "\"agent_workspace_nodes\".\"nodeType\" IN ('root', 'split', 'pane')" + }, + "agent_workspace_nodes_target_kind_chk": { + "name": "agent_workspace_nodes_target_kind_chk", + "value": "\"agent_workspace_nodes\".\"targetKind\" IS NULL OR \"agent_workspace_nodes\".\"targetKind\" IN ('chat', 'terminal', 'page')" + } + }, + "isRLSEnabled": false + }, + "public.drive_envs": { + "name": "drive_envs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "driveId": { + "name": "driveId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spriteKey": { + "name": "spriteKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandboxId": { + "name": "sandboxId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spriteInstanceId": { + "name": "spriteInstanceId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "egressPolicyToken": { + "name": "egressPolicyToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teardownRequestedAt": { + "name": "teardownRequestedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "spriteTornDownAt": { + "name": "spriteTornDownAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "storageLastBilledAt": { + "name": "storageLastBilledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "storageMeasuredBytes": { + "name": "storageMeasuredBytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "storageMeasuredAt": { + "name": "storageMeasuredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastActiveAt": { + "name": "lastActiveAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drive_envs_drive_id_idx": { + "name": "drive_envs_drive_id_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_envs_drive_name_idx": { + "name": "drive_envs_drive_name_idx", + "columns": [ + { + "expression": "driveId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drive_envs_live_sprite_idx": { + "name": "drive_envs_live_sprite_idx", + "columns": [ + { + "expression": "sandboxId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spriteTornDownAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"drive_envs\".\"sandboxId\" IS NOT NULL AND \"drive_envs\".\"spriteTornDownAt\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drive_envs_driveId_drives_id_fk": { + "name": "drive_envs_driveId_drives_id_fk", + "tableFrom": "drive_envs", + "tableTo": "drives", + "columnsFrom": [ + "driveId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drive_envs_createdBy_users_id_fk": { + "name": "drive_envs_createdBy_users_id_fk", + "tableFrom": "drive_envs", + "tableTo": "users", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.AuthProvider": { + "name": "AuthProvider", + "schema": "public", + "values": [ + "email", + "google", + "apple" + ] + }, + "public.PlatformType": { + "name": "PlatformType", + "schema": "public", + "values": [ + "web", + "desktop", + "ios", + "android" + ] + }, + "public.UserRole": { + "name": "UserRole", + "schema": "public", + "values": [ + "user", + "admin" + ] + }, + "public.DriveKind": { + "name": "DriveKind", + "schema": "public", + "values": [ + "STANDARD", + "HOME" + ] + }, + "public.FavoriteItemType": { + "name": "FavoriteItemType", + "schema": "public", + "values": [ + "page", + "drive" + ] + }, + "public.PageType": { + "name": "PageType", + "schema": "public", + "values": [ + "FOLDER", + "DOCUMENT", + "CHANNEL", + "AI_CHAT", + "CANVAS", + "FILE", + "SHEET", + "TASK_LIST", + "CODE", + "MACHINE" + ] + }, + "public.MemberRole": { + "name": "MemberRole", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "MEMBER" + ] + }, + "public.pulse_summary_type": { + "name": "pulse_summary_type", + "schema": "public", + "values": [ + "scheduled", + "on_demand", + "welcome" + ] + }, + "public.NotificationType": { + "name": "NotificationType", + "schema": "public", + "values": [ + "PERMISSION_GRANTED", + "PERMISSION_REVOKED", + "PERMISSION_UPDATED", + "PAGE_SHARED", + "DRIVE_INVITED", + "DRIVE_JOINED", + "DRIVE_ROLE_CHANGED", + "CONNECTION_REQUEST", + "CONNECTION_ACCEPTED", + "CONNECTION_REJECTED", + "NEW_DIRECT_MESSAGE", + "EMAIL_VERIFICATION_REQUIRED", + "TOS_PRIVACY_UPDATED", + "MENTION", + "TASK_ASSIGNED", + "PRODUCT_UPDATE" + ] + }, + "public.toast_notification_level": { + "name": "toast_notification_level", + "schema": "public", + "values": [ + "all", + "mentions", + "off" + ] + }, + "public.display_preference_type": { + "name": "display_preference_type", + "schema": "public", + "values": [ + "SHOW_TOKEN_COUNTS", + "SHOW_CODE_TOGGLE", + "DEFAULT_MARKDOWN_MODE" + ] + }, + "public.activity_change_group_type": { + "name": "activity_change_group_type", + "schema": "public", + "values": [ + "user", + "ai", + "automation", + "system" + ] + }, + "public.activity_resource": { + "name": "activity_resource", + "schema": "public", + "values": [ + "page", + "drive", + "permission", + "agent", + "user", + "member", + "role", + "file", + "token", + "device", + "message", + "conversation" + ] + }, + "public.content_format": { + "name": "content_format", + "schema": "public", + "values": [ + "text", + "html", + "json", + "tiptap" + ] + }, + "public.http_method": { + "name": "http_method", + "schema": "public", + "values": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS" + ] + }, + "public.log_level": { + "name": "log_level", + "schema": "public", + "values": [ + "trace", + "debug", + "info", + "warn", + "error", + "fatal" + ] + }, + "public.drive_backup_schedule_frequency": { + "name": "drive_backup_schedule_frequency", + "schema": "public", + "values": [ + "daily", + "weekly", + "monthly" + ] + }, + "public.drive_backup_source": { + "name": "drive_backup_source", + "schema": "public", + "values": [ + "manual", + "scheduled", + "pre_restore", + "system" + ] + }, + "public.drive_backup_status": { + "name": "drive_backup_status", + "schema": "public", + "values": [ + "pending", + "ready", + "failed" + ] + }, + "public.page_version_source": { + "name": "page_version_source", + "schema": "public", + "values": [ + "manual", + "auto", + "pre_ai", + "pre_restore", + "restore", + "system" + ] + }, + "public.ConnectionStatus": { + "name": "ConnectionStatus", + "schema": "public", + "values": [ + "PENDING", + "ACCEPTED", + "BLOCKED" + ] + }, + "public.PushPlatformType": { + "name": "PushPlatformType", + "schema": "public", + "values": [ + "ios", + "android", + "web" + ] + }, + "public.integration_connection_status": { + "name": "integration_connection_status", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "pending", + "revoked" + ] + }, + "public.integration_provider_type": { + "name": "integration_provider_type", + "schema": "public", + "values": [ + "builtin", + "openapi", + "custom", + "mcp", + "webhook" + ] + }, + "public.integration_visibility": { + "name": "integration_visibility", + "schema": "public", + "values": [ + "private", + "owned_drives", + "all_drives" + ] + }, + "public.AttendeeStatus": { + "name": "AttendeeStatus", + "schema": "public", + "values": [ + "PENDING", + "ACCEPTED", + "DECLINED", + "TENTATIVE" + ] + }, + "public.EventVisibility": { + "name": "EventVisibility", + "schema": "public", + "values": [ + "DRIVE", + "ATTENDEES_ONLY", + "PRIVATE" + ] + }, + "public.GoogleCalendarConnectionStatus": { + "name": "GoogleCalendarConnectionStatus", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "disconnected" + ] + }, + "public.RecurrenceFrequency": { + "name": "RecurrenceFrequency", + "schema": "public", + "values": [ + "DAILY", + "WEEKLY", + "MONTHLY", + "YEARLY" + ] + }, + "public.WorkflowTriggerType": { + "name": "WorkflowTriggerType", + "schema": "public", + "values": [ + "cron", + "event" + ] + }, + "public.WorkflowRunSourceTable": { + "name": "WorkflowRunSourceTable", + "schema": "public", + "values": [ + "taskTriggers", + "calendarTriggers", + "webhookTriggers", + "cron", + "manual" + ] + }, + "public.WorkflowRunStatus": { + "name": "WorkflowRunStatus", + "schema": "public", + "values": [ + "running", + "success", + "error", + "cancelled" + ] + }, + "public.WorkflowRunStepStatus": { + "name": "WorkflowRunStepStatus", + "schema": "public", + "values": [ + "running", + "success", + "error", + "skipped" + ] + }, + "public.TaskTriggerType": { + "name": "TaskTriggerType", + "schema": "public", + "values": [ + "due_date", + "completion" + ] + }, + "public.ZoomConnectionStatus": { + "name": "ZoomConnectionStatus", + "schema": "public", + "values": [ + "active", + "expired", + "error", + "disconnected" + ] + }, + "public.ContentTagAnchorStatus": { + "name": "ContentTagAnchorStatus", + "schema": "public", + "values": [ + "exact", + "shifted", + "fuzzy", + "orphaned" + ] + }, + "public.ContentTagSource": { + "name": "ContentTagSource", + "schema": "public", + "values": [ + "user", + "ai", + "system", + "rule" + ] + }, + "public.ContentTagTargetKind": { + "name": "ContentTagTargetKind", + "schema": "public", + "values": [ + "page", + "text", + "sheet_cell", + "channel_message", + "ai_message" + ] + }, + "public.custom_domain_status": { + "name": "custom_domain_status", + "schema": "public", + "values": [ + "pending", + "verified", + "failed", + "provisioning", + "active", + "dns_failed", + "cert_failed" + ] + }, + "public.data_subject_request_status": { + "name": "data_subject_request_status", + "schema": "public", + "values": [ + "pending", + "queued", + "in_progress", + "blocked", + "completed", + "failed", + "cancelled" + ] + }, + "public.data_subject_request_type": { + "name": "data_subject_request_type", + "schema": "public", + "values": [ + "erasure", + "export", + "access", + "rectification", + "restriction", + "portability", + "objection" + ] + }, + "public.data_subject_requester_type": { + "name": "data_subject_requester_type", + "schema": "public", + "values": [ + "self", + "admin" + ] + }, + "public.OAuthClientType": { + "name": "OAuthClientType", + "schema": "public", + "values": [ + "public", + "confidential" + ] + }, + "public.published_app_status": { + "name": "published_app_status", + "schema": "public", + "values": [ + "provisioning", + "building", + "deploying", + "running", + "stopped", + "parked", + "destroying", + "failed" + ] + }, + "public.published_app_tier": { + "name": "published_app_tier", + "schema": "public", + "values": [ + "metered", + "dedicated" + ] + }, + "public.broadcast_recipient_status": { + "name": "broadcast_recipient_status", + "schema": "public", + "values": [ + "pending", + "sent", + "skipped", + "failed" + ] + }, + "public.email_broadcast_content_mode": { + "name": "email_broadcast_content_mode", + "schema": "public", + "values": [ + "compose", + "template" + ] + }, + "public.email_broadcast_engine": { + "name": "email_broadcast_engine", + "schema": "public", + "values": [ + "transactional", + "resend_broadcast" + ] + }, + "public.email_broadcast_status": { + "name": "email_broadcast_status", + "schema": "public", + "values": [ + "draft", + "pending", + "queued", + "in_progress", + "paused", + "completed", + "failed", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 469c54c1fa..e3738c4c6b 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -1933,6 +1933,13 @@ "when": 1787709644968, "tag": "0275_mean_thaddeus_ross", "breakpoints": true + }, + { + "idx": 276, + "version": "7", + "when": 1787720287527, + "tag": "0276_cloudy_madame_hydra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index 269a541b71..2b735379bd 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -302,6 +302,10 @@ "./schema/published-apps": { "types": "./dist/schema/published-apps.d.ts", "default": "./dist/schema/published-apps.js" + }, + "./schema/published-app-subscriptions": { + "types": "./dist/schema/published-app-subscriptions.d.ts", + "default": "./dist/schema/published-app-subscriptions.js" } }, "scripts": { diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9178aceda5..3000cb99f5 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -55,6 +55,7 @@ export * from './schema/oauth'; export * from './schema/form-targets'; export * from './schema/machine-sprite-reclaims'; export * from './schema/published-apps'; +export * from './schema/published-app-subscriptions'; export * from './schema/email-broadcasts'; export * from './schema/page-webhooks'; export * from './schema/agent-workspaces'; @@ -117,6 +118,7 @@ import * as oauth from './schema/oauth'; import * as formTargets from './schema/form-targets'; import * as machineSpriteReclaims from './schema/machine-sprite-reclaims'; import * as publishedApps from './schema/published-apps'; +import * as publishedAppSubscriptions from './schema/published-app-subscriptions'; import * as emailBroadcasts from './schema/email-broadcasts'; import * as pageWebhooks from './schema/page-webhooks'; import * as agentWorkspaces from './schema/agent-workspaces'; @@ -180,6 +182,7 @@ export const schema = { ...formTargets, ...machineSpriteReclaims, ...publishedApps, + ...publishedAppSubscriptions, ...emailBroadcasts, ...pageWebhooks, ...agentWorkspaces, diff --git a/packages/db/src/schema/published-app-subscriptions.ts b/packages/db/src/schema/published-app-subscriptions.ts new file mode 100644 index 0000000000..2cc0e34a0f --- /dev/null +++ b/packages/db/src/schema/published-app-subscriptions.ts @@ -0,0 +1,151 @@ +import { pgTable, text, timestamp, boolean, index, check } from 'drizzle-orm/pg-core'; +import { relations, sql } from 'drizzle-orm'; +import { createId } from '@paralleldrive/cuid2'; +import { users } from './auth'; +import { publishedApps } from './published-apps'; + +/** + * publishedAppSubscriptions — the Stripe mirror for the DEDICATED hosting SKU: + * one flat monthly subscription per always-on published app. + * + * ───────────────────────────────────────────────────────────────────────────── + * WHY THIS IS NOT A ROW IN `subscriptions`, which is the question every reader + * arrives with. `subscriptions` is the ACCOUNT PLAN mirror, and it is read as + * one: `deriveTierFromSubscriptions` (billing/subscription-tier-sync.ts) walks a + * user's rows there and writes the winner into `users.subscriptionTier`, the + * column ~40 feature gates read. A dedicated-hosting subscription put in that + * table would be an ENTITLED row carrying a price the account tier map has never + * heard of, and that has two independent consequences, both bad: + * + * 1. The Stripe webhook derives from the SINGLE row in the event it just + * received. A hosting subscription's `customer.subscription.created` would + * therefore derive `free` and write it straight over a Pro / Founder / + * Business customer's tier — a paying user demoted by buying more. + * 2. The reconcile cron would then see `indeterminate: true` (an entitled row + * on an unmapped price) for that user forever, and `isTierDriftRepairable` + * deliberately refuses to auto-repair such a user. The demotion in (1) + * would be permanent AND invisible to the repair path built for exactly + * that failure. + * + * Hosting spend is not a plan. It hangs off a published app, it is bought and + * cancelled per app, and it says nothing about what the account is entitled to — + * so it gets its own mirror and its own webhook branch (routed on the + * subscription's `metadata.kind`, BEFORE the account-tier handler sees it). + * ───────────────────────────────────────────────────────────────────────────── + * + * The row is a MIRROR of Stripe, exactly as `subscriptions` is: Stripe is the + * source of truth for money, this table is the source of truth for "may this app + * be dedicated", and the webhook is the only thing that reconciles them. Nothing + * here is authoritative on its own. + */ +export const publishedAppSubscriptions = pgTable('published_app_subscriptions', { + id: text('id').primaryKey().$defaultFn(() => createId()), + + /** + * The app this subscription pays for. UNIQUE — an app has at most one + * dedicated subscription, and the uniqueness is what makes "is this app paid + * for" a single-row read rather than an aggregate over history. + * + * Cascades with the app: unpublishing deletes the `published_apps` row, and a + * mirror row pointing at a vanished app is not a record worth keeping. THE + * STRIPE SUBSCRIPTION IS NOT CANCELLED BY THAT CASCADE — cancelling at Stripe + * is an API call the delete path must make, and this comment is here so nobody + * reads the FK as if it did. A cascade that silently dropped the only local + * pointer to a live recurring charge is precisely the shape that bills a + * customer for an app that no longer exists. + */ + publishedAppId: text('publishedAppId') + .notNull() + .unique() + .references(() => publishedApps.id, { onDelete: 'cascade' }), + + /** + * Who is billed. Denormalized from the app's payer at purchase time so a + * customer-id lookup from a webhook lands on a user without joining through + * the app and its drive — and so the row survives as an attributable record if + * the drive later changes hands. + */ + userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }), + + /** Stripe's id for the subscription. Unique — the webhook's upsert target. */ + stripeSubscriptionId: text('stripeSubscriptionId').notNull().unique(), + + /** The flat monthly price actually charged. One price per guest preset. */ + stripePriceId: text('stripePriceId').notNull(), + + /** + * The guest size this subscription pays for, recorded at purchase. + * + * Deliberately duplicated from `published_apps.guestPreset` rather than read + * through it: the app's column is what we ask Fly for, and this is what the + * customer agreed to pay. They should agree, and a disagreement is exactly the + * thing worth being able to SEE — an app resized without its subscription + * being moved to the matching price is a machine we are paying Fly for and + * under-charging for, and it is undetectable if both facts live in one column. + */ + guestPreset: text('guestPreset').notNull(), + + /** Stripe's status verbatim: active, trialing, past_due, canceled, unpaid, incomplete… */ + status: text('status').notNull(), + + /** + * `event.created` of the Stripe event this row was last written from. NULL for + * a row written outside an event — the purchase path, which creates the + * subscription and mirrors it before any webhook has arrived. + * + * ORDERING, not auditing. Stripe does NOT order webhook deliveries: a + * `customer.subscription.updated` carrying `active` can arrive after the + * `deleted` that ended the subscription, and written blindly it re-entitles the + * app permanently — nothing further will arrive to correct it, because the + * subscription is already dead. This column is what lets a write be refused for + * being older than the one already applied. + * + * It is only HALF the guard, deliberately: `event.created` has one-second + * resolution, so two events in the same second are indistinguishable to it. The + * other half — terminal statuses being absorbing — carries the weight and needs + * no clock. See `planSubscriptionMirrorWrite`. + */ + stripeEventCreated: timestamp('stripeEventCreated', { mode: 'date', withTimezone: true }), + + currentPeriodStart: timestamp('currentPeriodStart', { mode: 'date', withTimezone: true }).notNull(), + currentPeriodEnd: timestamp('currentPeriodEnd', { mode: 'date', withTimezone: true }).notNull(), + cancelAtPeriodEnd: boolean('cancelAtPeriodEnd').default(false).notNull(), + + createdAt: timestamp('createdAt', { mode: 'date', withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { mode: 'date', withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), +}, (table) => ({ + userIdx: index('published_app_subscriptions_user_idx').on(table.userId), + stripeSubscriptionIdx: index('published_app_subscriptions_stripe_subscription_idx') + .on(table.stripeSubscriptionId), + // A status column that can be empty is a status column the entitlement read + // cannot trust — `''` is not a Stripe status and would silently fall out of + // every `IN (...)` the entitlement check makes, reading as "not entitled" for + // a subscription that may well be active. + statusNonEmpty: check( + 'published_app_subscriptions_status_nonempty', + sql`length(${table.status}) > 0`, + ), + // A period that ends before it starts prices nothing and dates nothing; it can + // only arrive from a bad write, and it would make any period arithmetic + // (proration, a "renews on" string) negative. + periodOrdered: check( + 'published_app_subscriptions_period_ordered', + sql`${table.currentPeriodEnd} >= ${table.currentPeriodStart}`, + ), +})); + +export type PublishedAppSubscription = typeof publishedAppSubscriptions.$inferSelect; + +export const publishedAppSubscriptionsRelations = relations(publishedAppSubscriptions, ({ one }) => ({ + app: one(publishedApps, { + fields: [publishedAppSubscriptions.publishedAppId], + references: [publishedApps.id], + }), + user: one(users, { + fields: [publishedAppSubscriptions.userId], + references: [users.id], + }), +})); diff --git a/packages/db/src/schema/published-apps.ts b/packages/db/src/schema/published-apps.ts index b8a0f84ad1..f61f4a372e 100644 --- a/packages/db/src/schema/published-apps.ts +++ b/packages/db/src/schema/published-apps.ts @@ -367,11 +367,30 @@ export const publishedApps = pgTable('published_apps', { 'published_apps_parked_is_metered_only', sql`${table.status} <> 'parked' OR ${table.tier} = 'metered'`, ), - // v1 ships exactly one guest size; this is where that decision is enforced rather - // than merely documented. + // The sellable guest sizes. Mirrors `PUBLISHED_APP_GUEST_PRESETS` in + // `services/app-hosting/dedicated-tier.ts`, which is the catalogue this list is + // generated from by hand; widening it is an additive migration and the pair is + // pinned by a test that writes a rejected preset to a real Postgres. guestPresetAllowed: check( 'published_apps_guest_preset_allowed', - sql`${table.guestPreset} IN ('shared-cpu-1x-512')`, + sql`${table.guestPreset} IN ('shared-cpu-1x-512', 'shared-cpu-1x-1024', 'shared-cpu-2x-2048', 'shared-cpu-4x-4096')`, + ), + // A METERED app may only run the v1 small guest, and this is an economics + // constraint rather than a preference. The awake-seconds meter prices every + // second at ONE fixed shape (`PUBLISHED_APP_GUEST_SHAPE`, which is this preset), + // because a published app's guest was a constant when that meter was written. A + // metered row on a larger preset would therefore be under-billed by exactly the + // difference between the two shapes — silently, with no error and no drift + // signal, for as long as the app ran. Bigger sizes are unlocked by moving to the + // DEDICATED tier, whose flat price is derived from the size it is selling. + // + // Stated as an implication on `metered` rather than as a per-tier allow-list so + // that adding a preset to the list above does not silently become a metered + // size: a new preset is dedicated-only here by construction, and making one + // metered-legal is a deliberate second edit that has to face this comment. + meteredGuestPreset: check( + 'published_apps_metered_guest_preset', + sql`${table.tier} <> 'metered' OR ${table.guestPreset} = 'shared-cpu-1x-512'`, ), // A negative image size is not a small number, it is a corrupt one — and it // would land in the economics dashboard's SUM as a silent credit against every diff --git a/packages/lib/package.json b/packages/lib/package.json index 17c867bfdd..1ef1fd0733 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -603,6 +603,16 @@ "import": "./dist/services/app-hosting/provisioner-core.js", "require": "./dist/services/app-hosting/provisioner-core.js" }, + "./services/app-hosting/dedicated-tier": { + "types": "./dist/services/app-hosting/dedicated-tier.d.ts", + "import": "./dist/services/app-hosting/dedicated-tier.js", + "require": "./dist/services/app-hosting/dedicated-tier.js" + }, + "./services/app-hosting/dedicated-tier-service": { + "types": "./dist/services/app-hosting/dedicated-tier-service.d.ts", + "import": "./dist/services/app-hosting/dedicated-tier-service.js", + "require": "./dist/services/app-hosting/dedicated-tier-service.js" + }, "./services/app-hosting/provisioner": { "types": "./dist/services/app-hosting/provisioner.d.ts", "import": "./dist/services/app-hosting/provisioner.js", diff --git a/packages/lib/src/compliance/export/gdpr-export-coverage.ts b/packages/lib/src/compliance/export/gdpr-export-coverage.ts index b241d9cca3..44cc890419 100644 --- a/packages/lib/src/compliance/export/gdpr-export-coverage.ts +++ b/packages/lib/src/compliance/export/gdpr-export-coverage.ts @@ -259,6 +259,18 @@ export const EXCLUDED_TABLES: Readonly> = { // the env (exported under its own categories) and in `pages`; this row is // infrastructure the drive owns and pays for. 'published_apps', + // The Stripe mirror for one published app's DEDICATED (flat monthly) tier — + // the same answer as `subscriptions` below, for the same reason. It records a + // recurring charge for a DRIVE's infrastructure: which app is always-on, at + // which guest size, on which Stripe price, and whether that subscription is + // paying. `userId` is the payer denormalized from the drive owner, so the row + // says who is billed for the drive's machine rather than anything the subject + // authored or that describes them. The subject's own billing relationship is + // already the account-plan `subscriptions` row and the credit ledger, all + // excluded on this same rationale; the Stripe-side record of the charge is the + // subject's to obtain from Stripe, and is not duplicated into a PageSpace + // export by a table whose subject is an app. + 'published_app_subscriptions', 'custom_domains', 'global_assistant_config', 'form_targets', diff --git a/packages/lib/src/monitoring/machine-pricing.ts b/packages/lib/src/monitoring/machine-pricing.ts index 3aec84983e..f55ed0299b 100644 --- a/packages/lib/src/monitoring/machine-pricing.ts +++ b/packages/lib/src/monitoring/machine-pricing.ts @@ -27,6 +27,7 @@ */ import { + MACHINE_MARKUP_BPS, MACHINE_RATES, MACHINE_ASSUMED_CPUS, MACHINE_ASSUMED_MEMORY_GB, @@ -96,3 +97,56 @@ export function calculateMachineStorageCostDollars(gbMonths: number): number { if (typeof gbMonths !== 'number' || !Number.isFinite(gbMonths) || gbMonths <= 0) return 0; return Number((gbMonths * MACHINE_STORAGE_USD_PER_GB_MONTH).toFixed(6)); } + +/** + * Hours in a billing month, for turning a per-hour substrate rate into a flat + * monthly figure. 730 = 8760/12, the conventional average — NOT the length of + * any particular month. + * + * Deliberately not env-overridable: it is a unit conversion, not a price. Every + * knob that should move a price ({@link MACHINE_RATES}, {@link MACHINE_MARKUP_BPS}) + * already is one, and a second, subtler lever on the same number would let a + * markup floor be evaded without touching the constant that names it. + */ +export const HOURS_PER_BILLING_MONTH = 730; + +/** + * The FLOOR price, in whole cents per month, for an always-on machine of this + * shape — the dedicated (flat monthly SKU) tier's cost basis. + * + * WHAT THIS IS FOR, because it is not a list price. The dedicated tier sells a + * machine that is awake all 730 hours, so its substrate cost is knowable in + * advance rather than metered — and the founder-set rule that Machine billing + * never falls below 1.5x real substrate cost ({@link MACHINE_MARKUP_BPS}, floored + * by `MACHINE_MARKUP_FLOOR_BPS}) has to bind on that SKU too. A flat price is set + * in Stripe by a human, so the only way for that floor to bind is for the code to + * REFUSE a price below it. This function is that threshold; the list price is + * whatever the operator configured in Stripe, and the guard only stops us selling + * an always-on machine for less than it costs us x1.5. + * + * Rounded UP to the cent: rounding a floor down would let a price a fraction of a + * cent under it pass a check whose entire purpose is that nothing passes under it. + * + * READ THE NUMBER IT PRODUCES BEFORE TRUSTING IT AS ECONOMICS. `MACHINE_RATES` is + * the published SPRITES rate for bursty sandbox runtime (active CPU-hour $0.07 + + * mem GB-hour $0.04375). Applied across a whole month it prices `shared-cpu-1x-512` + * at ~$100/month against a real Fly cost of roughly $3/month for the same guest. + * Reusing it here is the founder-economics interim decision (one rate table for + * every machine, no second pricing surface to keep in sync) and it is deliberately + * conservative in the safe direction — a floor set too high refuses a sale, a floor + * set too low sells at a loss. When hosting gets its own rate table, this function + * is the one place that changes. + * + * Returns 0 for a malformed shape rather than throwing or inventing a number: a + * zero floor makes the caller's guard pass, which is correct, because a shape we + * cannot price is one this function has no opinion about — the CALLER refuses an + * unknown preset (see `guestForPreset`), and a floor is not the place to relitigate + * that. + */ +export function calculateDedicatedMonthlyFloorCents(shape: MachineShape): number { + const { cpus, memoryGB } = shape; + if (!Number.isFinite(cpus) || !Number.isFinite(memoryGB) || cpus < 0 || memoryGB < 0) return 0; + const usdPerHour = cpus * MACHINE_RATES.usdPerCpuHour + memoryGB * MACHINE_RATES.usdPerMemGbHour; + const substrateCents = usdPerHour * HOURS_PER_BILLING_MONTH * 100; + return Math.ceil((substrateCents * MACHINE_MARKUP_BPS) / 10000); +} diff --git a/packages/lib/src/services/app-hosting/__tests__/app-lifecycle-metering.test.ts b/packages/lib/src/services/app-hosting/__tests__/app-lifecycle-metering.test.ts index 0ba8b269b4..1fc224cccf 100644 --- a/packages/lib/src/services/app-hosting/__tests__/app-lifecycle-metering.test.ts +++ b/packages/lib/src/services/app-hosting/__tests__/app-lifecycle-metering.test.ts @@ -206,6 +206,60 @@ describe('wakePublishedApp', () => { ); }); + it('does NOT gate a dedicated wake — the flat monthly price is what pays for it', async () => { + // Running the gate on a dedicated app is worse than pointless: an exhausted + // payer would be refused a wake and then sent to `parkPublishedApp`, which the + // status machine correctly refuses (`parked_is_metered_only`) — leaving an app + // that is neither woken nor parked, that the customer is paying for. + const { deps, gate, startMachine } = makeDeps(); + const row = appRow({ tier: 'dedicated' }); + seed(row, [[{ ...row, status: 'running' }]]); + + const result = await wakePublishedApp('app-1', deps); + + expect(result.outcome).toBe('woken'); + expect(gate, 'a dedicated wake must not consult the credit gate').not.toHaveBeenCalled(); + expect(startMachine).toHaveBeenCalled(); + }); + + it('opens NO billing window for a dedicated wake, only a boundary', async () => { + // NULL means "no awake window is open", which for a dedicated app is the + // literal truth: nothing accrues, nothing settles, nothing closes it. Stamping + // it anyway would leave every dedicated row carrying what looks like an + // unbilled liability and is not one. + const { deps } = makeDeps(); + const row = appRow({ tier: 'dedicated' }); + seed(row, [[{ ...row, status: 'running' }]]); + + await wakePublishedApp('app-1', deps); + + assert({ + given: 'a dedicated wake', + should: 'stamp the reconcile boundary but no watermark and no hold', + actual: mockDb.__state.updateSets[0], + expected: { + status: 'running', + lastWakeAt: NOW, + awakeBilledThrough: null, + awakeHoldId: null, + }, + }); + }); + + it('does not even resolve a payer for a dedicated wake', async () => { + // The payer lookup exists to answer "who is charged", and nobody is charged + // per-second here — so an app whose drive is mid-delete still wakes. + const resolvePayerId = vi.fn(async () => null); + const { deps } = makeDeps(); + const row = appRow({ tier: 'dedicated' }); + seed(row, [[{ ...row, status: 'running' }]]); + + const result = await wakePublishedApp('app-1', { ...deps, billing: { ...deps.billing, resolvePayerId } }); + + expect(resolvePayerId).not.toHaveBeenCalled(); + expect(result.outcome).toBe('woken'); + }); + it('given Fly refuses the start, should release the hold and stamp NOTHING', async () => { // Nothing started, so nothing may be billed — and a stranded hold would // suppress the payer's own spendable balance for its whole TTL. diff --git a/packages/lib/src/services/app-hosting/__tests__/awake-metering.integration.test.ts b/packages/lib/src/services/app-hosting/__tests__/awake-metering.integration.test.ts index 9b404845ee..0fa36d51b7 100644 --- a/packages/lib/src/services/app-hosting/__tests__/awake-metering.integration.test.ts +++ b/packages/lib/src/services/app-hosting/__tests__/awake-metering.integration.test.ts @@ -38,6 +38,7 @@ import { publishedApps, publishedAppMachineEvents } from '@pagespace/db/schema/p import { driveEnvs } from '@pagespace/db/schema/drive-envs'; import { assert } from '../../sandbox/__tests__/riteway'; import { defaultAwakeMeterDeps } from '../awake-meter'; +import { defaultIdleReaperDeps } from '../idle-reaper'; import { closeAppWindowAtBoundary, defaultAppLifecycleMeteringDeps, @@ -45,6 +46,7 @@ import { stopPublishedApp, type AppLifecycleMeteringDeps, } from '../app-lifecycle-metering'; +import { defaultReconcileSandboxStorageDeps } from '../../sandbox/sandbox-storage-billing'; import { awakeSecondsFromEvents } from '../app-metering-core'; import { mirrorFlyMachineEvents, @@ -781,4 +783,145 @@ describe.skipIf(dbSkipExplicitlyAllowed())('the billing CHECK constraints', () = // Seeded moments ago, and certainly not before this suite started running. expect(row!.storageLastBilledAt.getTime()).toBeGreaterThan(Date.now() - 5 * 60_000); }); + + it('refuses a metered app on a guest the awake meter cannot price', async () => { + // `published_apps_metered_guest_preset`: the awake meter prices every second + // at ONE fixed shape, so a metered row on a larger preset would be + // under-billed by exactly the difference — silently, with no error and no + // drift signal, for as long as the app ran. + await expect( + db + .update(publishedApps) + .set({ guestPreset: 'shared-cpu-4x-4096' }) + .where(eq(publishedApps.id, appId)), + ).rejects.toThrow(); + }); + + it('allows a larger guest once the app is dedicated', async () => { + // Sizes are unlocked by moving to a tier whose flat price already accounts for + // the size. Both columns move in ONE statement because neither shape is legal + // on its own: metered+large is refused above, and dedicated+small is fine but + // is not the row we are asserting. + await db + .update(publishedApps) + .set({ tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096' }) + .where(eq(publishedApps.id, appId)); + const [row] = await db + .select({ tier: publishedApps.tier, guestPreset: publishedApps.guestPreset }) + .from(publishedApps) + .where(eq(publishedApps.id, appId)); + assert({ + given: 'a dedicated app', + should: 'be allowed to run a larger guest', + actual: row, + expected: { tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096' }, + }); + }); + + it('refuses a guest preset that is not in the catalogue at all', async () => { + // `published_apps_guest_preset_allowed`. A preset in the constraint is a + // promise to support hardware we have run; anything else is a machine size + // nobody priced. + await expect( + db + .update(publishedApps) + .set({ tier: 'dedicated', guestPreset: 'shared-cpu-8x-8192' }) + .where(eq(publishedApps.id, appId)), + ).rejects.toThrow(); + }); + + it('refuses to park a dedicated app', async () => { + // `published_apps_parked_is_metered_only`. Parking IS credit-exhaustion + // enforcement, and an app with no credit gate cannot have been refused by one. + await expect( + db + .update(publishedApps) + .set({ tier: 'dedicated', status: 'parked' }) + .where(eq(publishedApps.id, appId)), + ).rejects.toThrow(); + }); }); + +describe.skipIf(dbSkipExplicitlyAllowed())('the awake meter\u2019s row source', () => { + it('lists a running METERED app', async () => { + const listed = await defaultAwakeMeterDeps.listRunningApps(); + assert({ + given: 'a running metered app', + should: 'be metered this tick', + actual: listed.some((row) => row.id === appId), + expected: true, + }); + }); + + it('does NOT list a running dedicated app', async () => { + // THE DOUBLE-CHARGE GUARD. A dedicated app pays a flat monthly price; if the + // awake meter also drained credits per second, the customer would pay twice + // for the same machine. The filter is in the row source, so this is the only + // place it can be proved. + await db.update(publishedApps).set({ tier: 'dedicated' }).where(eq(publishedApps.id, appId)); + const listed = await defaultAwakeMeterDeps.listRunningApps(); + assert({ + given: 'a running dedicated app', + should: 'be invisible to the awake-seconds meter', + actual: listed.some((row) => row.id === appId), + expected: false, + }); + }); +}); + +describe.skipIf(dbSkipExplicitlyAllowed())('the idle reaper\u2019s candidate source', () => { + it('offers a running METERED app to the reaper', async () => { + // The row is seeded with stamps an hour old, so it is idle by any threshold + // this test would pass. + const listed = await defaultIdleReaperDeps.listIdleCandidates({ idleSeconds: 60, now: new Date() }); + assert({ + given: 'an idle metered app', + should: 'be reapable', + actual: listed.some((row) => row.id === appId), + expected: true, + }); + }); + + it('NEVER offers a dedicated app, however idle it looks', async () => { + // THE ALWAYS-ON GUARANTEE. Nothing downstream will catch this if it breaks: + // `running -> stopped` is a legal transition for BOTH tiers (only + // `running -> parked` is metered-only), and it has to be, because an operator + // stop and a redeploy both need that edge. This predicate is the only thing + // keeping a machine up that somebody pays a flat monthly price for. + await db.update(publishedApps).set({ tier: 'dedicated' }).where(eq(publishedApps.id, appId)); + const listed = await defaultIdleReaperDeps.listIdleCandidates({ idleSeconds: 60, now: new Date() }); + assert({ + given: 'a dedicated app with no traffic for an hour', + should: 'be invisible to the idle reaper', + actual: listed.some((row) => row.id === appId), + expected: false, + }); + }); +}); + +describe.skipIf(dbSkipExplicitlyAllowed())('the storage meter\u2019s published-app row source', () => { + it('bills a metered app\u2019s rootfs', async () => { + const listed = await defaultReconcileSandboxStorageDeps.listPublishedAppRootfs(); + assert({ + given: 'a metered app', + should: 'have its rootfs drained from credits like any other machine storage', + actual: listed.some((row) => row.publishedAppId === appId), + expected: true, + }); + }); + + it('does NOT bill a dedicated app\u2019s rootfs', async () => { + // The other half of the double-charge guard. The flat monthly price already + // covers this machine — it is derived from CPU and memory at 1.5x the Sprites + // rate table, orders of magnitude above the $0.15/GB-month the image costs. + await db.update(publishedApps).set({ tier: 'dedicated' }).where(eq(publishedApps.id, appId)); + const listed = await defaultReconcileSandboxStorageDeps.listPublishedAppRootfs(); + assert({ + given: 'a dedicated app', + should: 'be invisible to the rootfs storage drain', + actual: listed.some((row) => row.publishedAppId === appId), + expected: false, + }); + }); +}); + diff --git a/packages/lib/src/services/app-hosting/__tests__/build-core.test.ts b/packages/lib/src/services/app-hosting/__tests__/build-core.test.ts index 22b784feff..be25de6d84 100644 --- a/packages/lib/src/services/app-hosting/__tests__/build-core.test.ts +++ b/packages/lib/src/services/app-hosting/__tests__/build-core.test.ts @@ -90,6 +90,7 @@ describe('machine config', () => { digest: DIGEST, guestPreset: 'shared-cpu-1x-512', publishedAppId: 'abc', + tier: 'metered', env: { FOO: 'bar', PORT: '3000' }, }); @@ -115,6 +116,7 @@ describe('machine config', () => { digest: DIGEST, guestPreset: 'performance-8x', publishedAppId: 'abc', + tier: 'metered', }), expected: null, }); @@ -387,3 +389,84 @@ describe('reconciler policy', () => { expected: DEFAULT_BUILD_RECONCILER_POLICY.batchLimit, }); }); + +describe('machine config: the tier', () => { + const build = (tier: 'metered' | 'dedicated', guestPreset = 'shared-cpu-1x-512') => + buildMachineConfig({ + flyAppName: 'pgs-app-abc', + digest: DIGEST, + guestPreset, + publishedAppId: 'abc', + tier, + }); + + /** The single service the router replays into. */ + const service = (tier: 'metered' | 'dedicated', preset?: string) => + (build(tier, preset)?.services?.[0] ?? null) as Record | null; + + assert({ + given: 'a metered app', + should: 'run no machines when idle — scale-to-zero is the metered product', + actual: service('metered')?.min_machines_running, + expected: 0, + }); + + assert({ + given: 'a dedicated app', + should: 'keep one machine running — always-on is what the flat price buys', + actual: service('dedicated')?.min_machines_running, + expected: 1, + }); + + // Autostart stays on for BOTH tiers: for metered it is how a request reaches a + // scaled-to-zero app at all, and for dedicated it is how Fly's proxy recovers a + // machine Fly stopped for its own reasons (a host migration, an OOM) without + // waiting for our next tick. + assert({ + given: 'either tier', + should: 'let Fly’s proxy start a stopped machine', + actual: [service('metered')?.autostart, service('dedicated')?.autostart], + expected: [true, true], + }); + + // `autostop: off` is what makes every billing boundary an API call we made. + assert({ + given: 'either tier', + should: 'leave stopping to our own orchestrator', + actual: [service('metered')?.autostop, service('dedicated')?.autostop], + expected: ['off', 'off'], + }); + + // The tier is a billing fact. If it started moving image, env, ports or metadata + // as well, a tier change would quietly become a redeploy. + assert({ + given: 'the same app built on each tier', + should: 'differ in nothing but the services array', + actual: { ...build('metered'), services: undefined }, + expected: { ...build('dedicated'), services: undefined }, + }); +}); + +describe('the larger guest presets', () => { + assert({ + given: 'the dedicated-only presets', + should: 'map to the shapes the flat prices were derived from', + actual: [ + guestForPreset('shared-cpu-1x-1024'), + guestForPreset('shared-cpu-2x-2048'), + guestForPreset('shared-cpu-4x-4096'), + ], + expected: [ + { cpu_kind: 'shared', cpus: 1, memory_mb: 1024 }, + { cpu_kind: 'shared', cpus: 2, memory_mb: 2048 }, + { cpu_kind: 'shared', cpus: 4, memory_mb: 4096 }, + ], + }); + + assert({ + given: 'a preset nobody priced', + should: 'refuse rather than widen the fleet by accident', + actual: guestForPreset('shared-cpu-8x-8192'), + expected: null, + }); +}); diff --git a/packages/lib/src/services/app-hosting/__tests__/dedicated-tier-service.test.ts b/packages/lib/src/services/app-hosting/__tests__/dedicated-tier-service.test.ts new file mode 100644 index 0000000000..9d4678ce0e --- /dev/null +++ b/packages/lib/src/services/app-hosting/__tests__/dedicated-tier-service.test.ts @@ -0,0 +1,828 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { assert } from '../../sandbox/__tests__/riteway'; + +/** + * A db double recording every UPDATE's SET payload and the row it matched. + * + * `setPublishedAppTier` reads FOR UPDATE inside a transaction and then writes + * guarded on the tier it planned against, so the double has to carry `.for()` on + * the select chain and `.returning()` on the update chain. + */ +const mockDb = vi.hoisted(() => { + const state: { + rows: unknown[]; + updateSets: Array>; + /** Rows the guarded UPDATE ... RETURNING gives back, in order. */ + returning: unknown[][]; + inserted: Array>; + } = { rows: [], updateSets: [], returning: [], inserted: [] }; + + // Three shapes are used against this double: `.where(...)` awaited directly + // (the dunning survey), `.where(...).limit(1)` awaited, and + // `.where(...).limit(1).for('update')`. So both `where` and `limit` return a + // thenable that also carries the next link in the chain. + const select = () => ({ + from: () => ({ + where: () => ({ + then: (resolve: (v: unknown) => unknown) => Promise.resolve(state.rows).then(resolve), + limit: () => ({ + then: (r: (v: unknown) => unknown) => Promise.resolve(state.rows).then(r), + for: async () => state.rows, + }), + }), + }), + }); + + const update = () => ({ + set: (payload: Record) => { + state.updateSets.push(payload); + return { where: () => ({ returning: async () => state.returning.shift() ?? [] }) }; + }, + }); + + const insert = () => ({ + values: (payload: Record) => { + state.inserted.push(payload); + return { onConflictDoUpdate: () => ({ returning: async () => [payload] }) }; + }, + }); + + return { + __state: state, + select, + update, + insert, + // The mirror write upserts INSIDE the transaction (it reads FOR UPDATE first), + // so the tx double has to carry `insert` too. + transaction: async (cb: (tx: unknown) => Promise) => cb({ select, update, insert }), + }; +}); +vi.mock('@pagespace/db/db', () => ({ db: mockDb })); +// Full factory mocks (no `importOriginal`) so this suite runs against SOURCE and +// needs no built @pagespace/db: the module under test uses the operators only to +// build opaque predicate objects the db double ignores, and the table objects only +// as identity handles. +vi.mock('@pagespace/db/operators', () => ({ + and: (...a: unknown[]) => ({ op: 'and', a }), + eq: (a: unknown, b: unknown) => ({ op: 'eq', a, b }), +})); +vi.mock('@pagespace/db/schema/published-apps', () => ({ publishedApps: { id: 'published_apps.id', tier: 'published_apps.tier' } })); +vi.mock('@pagespace/db/schema/published-app-subscriptions', () => ({ + publishedAppSubscriptions: { publishedAppId: 'pas.publishedAppId', stripeSubscriptionId: 'pas.stripeSubscriptionId' }, +})); + +const mockIsEnabled = vi.hoisted(() => vi.fn(() => true)); +const mockIsBillingEnabled = vi.hoisted(() => vi.fn(() => true)); +vi.mock('../app-hosting-env', async (importOriginal) => ({ + ...(await importOriginal>()), + isAppHostingEnabled: mockIsEnabled, + resolveFlyMachinesToken: () => 'token', +})); +vi.mock('../../../deployment-mode', async (importOriginal) => ({ + ...(await importOriginal>()), + isBillingEnabled: mockIsBillingEnabled, +})); +// The real credit-consume reaches for `@pagespace/db/schema/credits` at module +// load. Only the DEFAULT deps binding uses it, and every test here injects its +// own `releaseHold`, so the module is stubbed to keep this suite runnable against +// source with no built @pagespace/db. +vi.mock('../../../billing/credit-consume', () => ({ releaseHold: vi.fn(async () => undefined) })); +const mockStopPublishedApp = vi.hoisted(() => + vi.fn(async (): Promise> => ({ outcome: 'stopped' })), +); +vi.mock('../app-lifecycle-metering', () => ({ stopPublishedApp: mockStopPublishedApp })); +vi.mock('../../../logging/logger-config', () => ({ + loggers: { ai: { warn: vi.fn(), error: vi.fn(), info: vi.fn() } }, +})); + +import { + defaultDedicatedTierDeps, + UNPAID_DOWNGRADE_REASON, + enforceUnpaidDedicated, + DEDICATED_DUNNING_VISIBILITY_DAYS, + isDedicatedTierPurchasable, + recordDedicatedSubscription, + setPublishedAppTier, + surveyDedicatedDunning, + syncAppTierToSubscription, + type DedicatedTierDeps, +} from '../dedicated-tier-service'; + +const APP = { + id: 'app_1', + driveId: 'drive_1', + flyAppName: 'pgs-app-app_1', + machineId: 'm_1', + status: 'running', + tier: 'metered', + guestPreset: 'shared-cpu-1x-512', + lastError: null, +}; + +function deps(overrides: Partial = {}): DedicatedTierDeps { + return { + isEnabled: () => true, + updateMachineConfig: vi.fn(async () => undefined), + releaseHold: vi.fn(async () => undefined), + stopApp: vi.fn(async () => ({ stopped: true })), + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDb.__state.rows = []; + mockDb.__state.updateSets.length = 0; + mockDb.__state.returning.length = 0; + mockDb.__state.inserted.length = 0; + mockIsEnabled.mockReturnValue(true); + mockIsBillingEnabled.mockReturnValue(true); +}); + +describe('isDedicatedTierPurchasable', () => { + it('is false where billing is disabled, so no Stripe call is ever attempted', () => { + // Tenant and onprem: hosting is unlimited by design and there is no customer + // to charge, so "buy always-on" cannot happen — and the honest shape of that + // is a flag that refuses, not a Stripe call that errors. + mockIsBillingEnabled.mockReturnValue(false); + assert({ + given: 'a deployment with billing disabled', + should: 'not offer the dedicated tier for sale', + actual: isDedicatedTierPurchasable(), + expected: false, + }); + }); + + it('is false while hosting is dark', () => { + mockIsEnabled.mockReturnValue(false); + assert({ + given: 'APP_HOSTING_ENABLED unset', + should: 'not offer the dedicated tier for sale', + actual: isDedicatedTierPurchasable(), + expected: false, + }); + }); + + it('is true on a billing-enabled deployment with hosting on', () => { + assert({ + given: 'cloud with hosting enabled', + should: 'offer the dedicated tier', + actual: isDedicatedTierPurchasable(), + expected: true, + }); + }); +}); + +describe('setPublishedAppTier', () => { + it('un-parks in the SAME statement as the tier write', async () => { + // `published_apps_parked_is_metered_only` makes a parked dedicated row + // unrepresentable, so "set the tier, then fix the status" is two statements of + // which the first cannot commit. + mockDb.__state.rows = [{ ...APP, status: 'parked', lastError: 'parked: insufficient_credits' }]; + mockDb.__state.returning = [[{ ...APP, status: 'stopped', tier: 'dedicated' }]]; + + const result = await setPublishedAppTier('app_1', 'dedicated', deps()); + + expect(result.ok).toBe(true); + assert({ + given: 'an upgrade of a parked app', + should: 'write the tier, the un-parked status and the cleared reason together', + actual: mockDb.__state.updateSets[0], + // The window columns are nulled on every upgrade — already null here, and + // the write is the same one that closes a LIVE app's window (below). + expected: { + tier: 'dedicated', + status: 'stopped', + lastError: null, + awakeBilledThrough: null, + awakeHoldId: null, + }, + }); + }); + + it('clears the park reason so the publish surface stops blaming credits', async () => { + mockDb.__state.rows = [{ ...APP, status: 'parked', lastError: 'parked: insufficient_credits' }]; + mockDb.__state.returning = [[{ ...APP, status: 'stopped', tier: 'dedicated' }]]; + await setPublishedAppTier('app_1', 'dedicated', deps()); + assert({ + given: 'an app that just paid for always-on', + should: 'no longer carry an out-of-credits reason', + actual: mockDb.__state.updateSets[0].lastError, + expected: null, + }); + }); + + it('leaves a running app running', async () => { + mockDb.__state.rows = [{ ...APP, status: 'running' }]; + mockDb.__state.returning = [[{ ...APP, tier: 'dedicated' }]]; + await setPublishedAppTier('app_1', 'dedicated', deps()); + assert({ + given: 'an upgrade of a live app', + should: 'change the tier and leave the status alone', + actual: mockDb.__state.updateSets[0], + expected: { + tier: 'dedicated', + status: 'running', + awakeBilledThrough: null, + awakeHoldId: null, + }, + }); + }); + + it('pushes min_machines_running to the live machine through the merge path', async () => { + mockDb.__state.rows = [{ ...APP }]; + mockDb.__state.returning = [[{ ...APP, tier: 'dedicated' }]]; + const updateMachineConfig = vi.fn(async (_app: string, _machine: string, merge: (c: never) => unknown) => { + // Prove the callback is a MERGE over the live config, not a fresh object: + // a partial post would delete the app's services with a 200 OK. + const merged = merge({ image: 'img', services: [{ internal_port: 8080 }] } as never) as { + image: string; + services: Array>; + }; + expect(merged.image, 'the live image must survive the merge').toBe('img'); + expect(merged.services[0].min_machines_running).toBe(1); + }); + + const result = await setPublishedAppTier('app_1', 'dedicated', deps({ updateMachineConfig })); + + expect(updateMachineConfig).toHaveBeenCalledWith('pgs-app-app_1', 'm_1', expect.any(Function)); + assert({ + given: 'a successful push', + should: 'report the machine as synced', + actual: result.ok === true ? result.machineConfigSynced : null, + expected: true, + }); + }); + + it('keeps the paid-for tier when Fly is unreachable', async () => { + // Rolling the tier back because Fly was briefly down would undo a paid upgrade + // over a transient network error. The row is the source of truth; the machine + // config is a projection the next deploy re-derives anyway. + mockDb.__state.rows = [{ ...APP }]; + mockDb.__state.returning = [[{ ...APP, tier: 'dedicated' }]]; + const result = await setPublishedAppTier( + 'app_1', + 'dedicated', + deps({ updateMachineConfig: vi.fn(async () => { throw new Error('flaps 503'); }) }), + ); + assert({ + given: 'a tier change whose machine push failed', + should: 'still be a successful tier change', + actual: result.ok, + expected: true, + }); + assert({ + given: 'a failed push', + should: 'report the machine as unsynced so it can be repaired', + actual: result.ok === true ? [result.machineConfigSynced, result.machineConfigError] : null, + expected: [false, 'flaps 503'], + }); + }); + + it('does not call Fly for an app with no machine yet', async () => { + mockDb.__state.rows = [{ ...APP, machineId: null, status: 'building' }]; + mockDb.__state.returning = [[{ ...APP, machineId: null, status: 'building', tier: 'dedicated' }]]; + const updateMachineConfig = vi.fn(); + const result = await setPublishedAppTier('app_1', 'dedicated', deps({ updateMachineConfig })); + expect(updateMachineConfig).not.toHaveBeenCalled(); + assert({ + given: 'an app that has never deployed', + should: 'report synced — the next deploy builds the config from the tier', + actual: result.ok === true ? result.machineConfigSynced : null, + expected: true, + }); + }); + + + it('closes the awake window and RETURNS the hold when upgrading a live app', async () => { + // The moment the tier changes, the awake meter stops listing this row — so + // nothing would ever settle the open window or release the wake's hold, and + // the reservation would suppress the payer's spendable balance for its whole + // TTL against a charge that is never coming. + mockDb.__state.rows = [{ ...APP, status: 'running', awakeBilledThrough: new Date(), awakeHoldId: 'hold-live' }]; + mockDb.__state.returning = [[{ ...APP, tier: 'dedicated' }]]; + const releaseHold = vi.fn(async () => undefined); + + await setPublishedAppTier('app_1', 'dedicated', deps({ releaseHold })); + + assert({ + given: 'an upgrade of an app that is awake and being metered', + should: 'close the billing window in the same statement as the tier', + actual: mockDb.__state.updateSets[0], + expected: { + tier: 'dedicated', + status: 'running', + awakeBilledThrough: null, + awakeHoldId: null, + }, + }); + expect(releaseHold, 'the wake’s reservation must be returned').toHaveBeenCalledWith('hold-live'); + }); + + it('does not close a window when DOWNgrading — the meter reopens one itself', async () => { + // A metered row with no watermark is exactly the case the awake meter's + // "no window" branch handles: it stamps at NOW, bills nothing for the unknown + // span, and places a fresh hold. + mockDb.__state.rows = [{ ...APP, tier: 'dedicated', status: 'running', awakeBilledThrough: null, awakeHoldId: null }]; + mockDb.__state.returning = [[{ ...APP, tier: 'metered' }]]; + const releaseHold = vi.fn(async () => undefined); + + await setPublishedAppTier('app_1', 'metered', deps({ releaseHold })); + + assert({ + given: 'a downgrade', + should: 'touch only the tier and status', + actual: mockDb.__state.updateSets[0], + expected: { tier: 'metered', status: 'running' }, + }); + expect(releaseHold).not.toHaveBeenCalled(); + }); + + it('still reports a successful upgrade when the hold could not be returned', async () => { + // The hold expires on its own TTL. Throwing here would report a COMMITTED + // upgrade as a failure and invite a retry that finds `same_tier`. + mockDb.__state.rows = [{ ...APP, status: 'running', awakeBilledThrough: new Date(), awakeHoldId: 'hold-live' }]; + mockDb.__state.returning = [[{ ...APP, tier: 'dedicated' }]]; + const result = await setPublishedAppTier( + 'app_1', + 'dedicated', + deps({ releaseHold: vi.fn(async () => { throw new Error('ledger down'); }) }), + ); + assert({ + given: 'a committed upgrade whose hold release failed', + should: 'still be a successful tier change', + actual: result.ok, + expected: true, + }); + }); + + it('refuses a downgrade that would strand the app on an unmeterable guest', async () => { + mockDb.__state.rows = [{ ...APP, tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096' }]; + const result = await setPublishedAppTier('app_1', 'metered', deps()); + assert({ + given: 'a big dedicated app being downgraded', + should: 'refuse rather than silently resize a serving machine', + actual: result, + expected: { ok: false, reason: 'guest_preset_not_allowed' }, + }); + assert({ + given: 'a refused tier change', + should: 'write nothing', + actual: mockDb.__state.updateSets, + expected: [], + }); + }); + + it('is inert while hosting is dark', async () => { + const result = await setPublishedAppTier('app_1', 'dedicated', deps({ isEnabled: () => false })); + assert({ + given: 'a disabled deployment', + should: 'refuse without reading anything', + actual: result, + expected: { ok: false, reason: 'disabled' }, + }); + }); +}); + +describe('syncAppTierToSubscription', () => { + it('acks a subscription nothing local points at', async () => { + // Stripe redelivers forever against a row that is never going to appear (a + // subscription created against another environment's database, the common + // test-mode case), so this must be an ack rather than a throw. + const outcome = await syncAppTierToSubscription(null); + assert({ + given: 'no mirror row for the event', + should: 'report it rather than throw', + actual: outcome, + expected: { outcome: 'unknown_subscription' }, + }); + }); + + it('takes the tier from the MIRROR, never from an event we refused', async () => { + // The guard would be worse than useless if the write could be refused and the + // tier move anyway: it would look defended and not be. + mockDb.__state.rows = [{ ...APP, tier: 'dedicated', status: 'running' }]; + mockDb.__state.returning = [[{ ...APP, tier: 'metered' }]]; + + const outcome = await syncAppTierToSubscription({ publishedAppId: 'app_1', status: 'canceled' }); + + assert({ + given: 'a mirror row that says canceled', + should: 'downgrade the app, whatever any event claimed', + actual: outcome, + expected: { outcome: 'downgraded', publishedAppId: 'app_1', tierChanged: true }, + }); + assert({ + given: 'the downgrade', + should: 'write the metered tier', + actual: mockDb.__state.updateSets[0].tier, + expected: 'metered', + }); + }); +}); + +describe('surveyDedicatedDunning', () => { + const NOW = new Date('2026-08-25T00:00:00.000Z'); + const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); + + it('counts only the apps overdue past the visibility threshold', () => { + // The bound on the past_due free ride is a STRIPE ACCOUNT SETTING, not code. + // This counter is the only thing that would ever notice an account configured + // to leave failed subscriptions past_due forever. + mockDb.__state.rows = [ + { publishedAppId: 'fresh', currentPeriodEnd: daysAgo(1) }, + { publishedAppId: 'also_fresh', currentPeriodEnd: daysAgo(DEDICATED_DUNNING_VISIBILITY_DAYS - 1) }, + { publishedAppId: 'stale', currentPeriodEnd: daysAgo(DEDICATED_DUNNING_VISIBILITY_DAYS + 1) }, + { publishedAppId: 'very_stale', currentPeriodEnd: daysAgo(90) }, + ]; + return surveyDedicatedDunning(NOW).then((survey) => { + assert({ + given: 'four past_due dedicated apps, two of them long overdue', + should: 'report every past_due app and name only the long-overdue ones', + actual: survey, + expected: { pastDue: 4, pastDueStale: 2, staleAppIds: ['stale', 'very_stale'] }, + }); + }); + }); + + it('does not count a subscription still inside a normal retry window', async () => { + // A card that is going to work has worked well before the threshold, so a row + // under it means dunning is still in progress rather than not converging. + mockDb.__state.rows = [{ publishedAppId: 'retrying', currentPeriodEnd: daysAgo(2) }]; + const survey = await surveyDedicatedDunning(NOW); + assert({ + given: 'a subscription two days overdue', + should: 'be counted as past_due but not as stale', + actual: [survey.pastDue, survey.pastDueStale], + expected: [1, 0], + }); + }); + + it('reports nothing while hosting is dark', async () => { + mockIsEnabled.mockReturnValue(false); + mockDb.__state.rows = [{ publishedAppId: 'stale', currentPeriodEnd: daysAgo(90) }]; + assert({ + given: 'a deployment with hosting switched off', + should: 'read nothing and report zeroes', + actual: await surveyDedicatedDunning(NOW), + expected: { pastDue: 0, pastDueStale: 0, staleAppIds: [] }, + }); + }); +}); + +describe('recordDedicatedSubscription', () => { + const facts = (over: Record = {}) => ({ + publishedAppId: 'app_1', + userId: 'user_1', + stripeSubscriptionId: 'sub_1', + stripePriceId: 'price_1', + guestPreset: 'shared-cpu-1x-512', + status: 'active', + currentPeriodStart: new Date('2026-08-01T00:00:00Z'), + currentPeriodEnd: new Date('2026-09-01T00:00:00Z'), + cancelAtPeriodEnd: false, + stripeEventCreated: new Date('2026-08-25T12:00:00Z'), + ...over, + }); + + const stored = (over: Record = {}) => ({ + id: 'pas_1', + publishedAppId: 'app_1', + userId: 'user_1', + stripeSubscriptionId: 'sub_1', + stripePriceId: 'price_1', + guestPreset: 'shared-cpu-1x-512', + status: 'canceled', + stripeEventCreated: new Date('2026-08-25T12:00:00Z'), + ...over, + }); + + it('writes the row and stamps the event it came from', async () => { + mockDb.__state.rows = []; + const result = await recordDedicatedSubscription(facts()); + assert({ + given: 'a first write', + should: 'apply', + actual: result.outcome, + expected: 'applied', + }); + assert({ + given: 'the applied write', + should: 'carry the ordering stamp', + actual: mockDb.__state.inserted[0].stripeEventCreated, + expected: new Date('2026-08-25T12:00:00Z'), + }); + }); + + it('REFUSES a late event for a subscription that has already ended, and writes nothing', async () => { + // The permanent re-entitlement: applied, this would leave an always-on app + // with no paying subscription and no further event coming to correct it. + mockDb.__state.rows = [stored({ status: 'canceled' })]; + const result = await recordDedicatedSubscription( + facts({ status: 'active', stripeEventCreated: new Date('2026-08-25T13:00:00Z') }), + ); + assert({ + given: 'a late active event against a canceled row', + should: 'be absorbed', + actual: result.outcome, + expected: 'terminal_absorbed', + }); + assert({ + given: 'an absorbed event', + should: 'write nothing at all', + actual: mockDb.__state.inserted, + expected: [], + }); + }); + + it('hands the STORED row back on a refusal, so the tier follows what we believe', async () => { + // Returning nothing here would leave the caller with only the event it was + // just told to disbelieve. + mockDb.__state.rows = [stored({ status: 'canceled' })]; + const result = await recordDedicatedSubscription(facts({ status: 'active' })); + assert({ + given: 'a refused write', + should: 'return the row the mirror still holds', + actual: result.row?.status, + expected: 'canceled', + }); + }); + + it('refuses an event older than the one already applied', async () => { + mockDb.__state.rows = [stored({ status: 'active' })]; + const result = await recordDedicatedSubscription( + facts({ status: 'past_due', stripeEventCreated: new Date('2026-08-25T11:00:00Z') }), + ); + assert({ + given: 'a stale event', + should: 'be refused', + actual: [result.outcome, mockDb.__state.inserted.length], + expected: ['stale_event', 0], + }); + }); + + it('lets a NEW subscription write over a terminal row', async () => { + mockDb.__state.rows = [stored({ status: 'canceled' })]; + const result = await recordDedicatedSubscription( + facts({ stripeSubscriptionId: 'sub_2', stripeEventCreated: new Date('2026-08-25T13:00:00Z') }), + ); + assert({ + given: 'a re-buy', + should: 'apply — a new subscription id is the re-entitlement path', + actual: result.outcome, + expected: 'applied', + }); + }); + + it('is inert while hosting is dark', async () => { + mockIsEnabled.mockReturnValue(false); + const result = await recordDedicatedSubscription(facts()); + assert({ + given: 'a disabled deployment', + should: 'write nothing', + actual: [result.row, mockDb.__state.inserted.length], + expected: [null, 0], + }); + }); +}); + +describe('enforceUnpaidDedicated', () => { + /** A dedicated app on a guest the metered tier may not run. */ + const BIG = { ...APP, tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096', status: 'running' }; + + it('STOPS the machine before touching anything else', async () => { + // This is what actually ends the cost, and it is first so that the resize + // below happens to an app that is already down rather than to a live one. + const stopApp = vi.fn(async () => ({ stopped: true })); + mockDb.__state.rows = [BIG]; + mockDb.__state.returning = [[{ ...BIG, tier: 'metered', guestPreset: 'shared-cpu-1x-512' }]]; + + await enforceUnpaidDedicated('app_1', deps({ stopApp })); + + expect(stopApp).toHaveBeenCalledWith('app_1'); + }); + + it('moves the tier and the guest in ONE statement', async () => { + // Neither shape is legal alone: `published_apps_metered_guest_preset` makes a + // metered row on a larger guest unrepresentable, so "set the tier, then + // resize" is two statements of which the first cannot commit. + mockDb.__state.rows = [BIG]; + mockDb.__state.returning = [[{ ...BIG, tier: 'metered', guestPreset: 'shared-cpu-1x-512' }]]; + + const outcome = await enforceUnpaidDedicated('app_1', deps()); + + assert({ + given: 'an unpaid dedicated app on a big guest', + should: 'return to the metered tier at the default guest, with a reason the owner can read', + actual: mockDb.__state.updateSets[0], + expected: { + tier: 'metered', + status: 'running', + guestPreset: 'shared-cpu-1x-512', + lastError: UNPAID_DOWNGRADE_REASON, + }, + }); + assert({ + given: 'the enforcement', + should: 'report a real downgrade', + actual: outcome, + expected: { outcome: 'downgraded', publishedAppId: 'app_1', tierChanged: true }, + }); + }); + + it('pushes min_machines_running back to 0, or Fly restarts what we stopped', async () => { + // The row alone does not reach Fly: a live machine config still saying + // keep-one-up would have the proxy restart the machine we just stopped. + mockDb.__state.rows = [BIG]; + mockDb.__state.returning = [[{ ...BIG, tier: 'metered', guestPreset: 'shared-cpu-1x-512' }]]; + let pushed: number | undefined; + const updateMachineConfig = vi.fn(async (_a: string, _m: string, merge: (c: never) => unknown) => { + const merged = merge({ services: [{ internal_port: 8080, min_machines_running: 1 }] } as never) as { + services: Array>; + }; + pushed = merged.services[0].min_machines_running; + }); + + await enforceUnpaidDedicated('app_1', deps({ updateMachineConfig })); + + assert({ + given: 'an app taken off the dedicated tier', + should: 'stop being kept up by Fly', + actual: pushed, + expected: 0, + }); + }); + + it('treats an already-enforced app as done, not as a fault', async () => { + // Stripe redelivers, so a second `deleted` for a subscription already enforced + // arrives with nothing left to do. Reporting that as a refusal would raise an + // operator alert on every redelivery for work that is finished. + mockDb.__state.rows = [{ ...APP, tier: 'metered', guestPreset: 'shared-cpu-1x-512', status: 'stopped' }]; + const outcome = await enforceUnpaidDedicated('app_1', deps()); + assert({ + given: 'an app already stopped and already metered', + should: 'report the state reached, with nothing changed', + actual: outcome, + expected: { outcome: 'downgraded', publishedAppId: 'app_1', tierChanged: false }, + }); + }); + + it('ABORTS when the stop was REFUSED rather than thrown', async () => { + // `stopPublishedApp` reports every refusal as a VALUE — `stop_failed` when Fly + // refused, `lock_busy` when the awake meter's advisory lock meant nothing was + // read or stopped at all. A guard that only caught exceptions would catch + // nothing and resize a machine that is still running. + for (const error of ['flaps 503', 'lock_busy']) { + mockDb.__state.updateSets.length = 0; + mockDb.__state.rows = [BIG]; + const outcome = await enforceUnpaidDedicated( + 'app_1', + deps({ stopApp: vi.fn(async () => ({ stopped: false, error })) }), + ); + expect(outcome, `a ${error} stop must abort`).toEqual({ + outcome: 'tier_change_refused', + publishedAppId: 'app_1', + reason: 'stop_failed', + }); + expect(mockDb.__state.updateSets, 'nothing may be written').toEqual([]); + } + }); + + it('treats an ALREADY-STOPPED app as stopped and proceeds', async () => { + // There is no machine left to end, and refusing here would block the resize on + // the one case where the resize is safest. + mockDb.__state.rows = [{ ...BIG, status: 'stopped' }]; + mockDb.__state.returning = [[{ ...BIG, status: 'stopped', tier: 'metered', guestPreset: 'shared-cpu-1x-512' }]]; + const outcome = await enforceUnpaidDedicated( + 'app_1', + deps({ stopApp: vi.fn(async () => ({ stopped: true })) }), + ); + assert({ + given: 'an app that was already down', + should: 'still be returned to the metered tier', + actual: outcome.outcome, + expected: 'downgraded', + }); + }); + + it('ABORTS if the stop threw', async () => { + // Resizing an app we could not stop would leave a running machine whose row + // promises a guest it is not on — and it would still be always-on. + mockDb.__state.rows = [BIG]; + const outcome = await enforceUnpaidDedicated( + 'app_1', + // A REPORTED refusal, not a throw — `stopPublishedApp` never throws, so a + // guard that only caught exceptions would sail straight past a machine that + // is still running. + deps({ stopApp: vi.fn(async () => ({ stopped: false, error: 'flaps 503' })) }), + ); + assert({ + given: 'a stop that failed', + should: 'report rather than resize a running machine', + actual: outcome, + expected: { outcome: 'tier_change_refused', publishedAppId: 'app_1', reason: 'stop_failed' }, + }); + assert({ + given: 'an aborted enforcement', + should: 'leave the row exactly as it was', + actual: mockDb.__state.updateSets, + expected: [], + }); + }); +}); + +describe('syncAppTierToSubscription on a subscription that stopped paying', () => { + it('ENFORCES the downgrade when the plain one cannot be expressed', async () => { + // Codex P1: leaving this as a logged refusal means an app nobody pays for + // keeps its always-on config and stays out of BOTH meters forever, because no + // further event is coming for a dead subscription. + const stopApp = vi.fn(async () => ({ stopped: true })); + mockDb.__state.rows = [{ ...APP, tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096', status: 'running' }]; + mockDb.__state.returning = [[{ ...APP, tier: 'metered', guestPreset: 'shared-cpu-1x-512' }]]; + + const outcome = await syncAppTierToSubscription( + { publishedAppId: 'app_1', status: 'canceled' }, + deps({ stopApp }), + ); + + expect(stopApp, 'the unpaid machine must actually be stopped').toHaveBeenCalledWith('app_1'); + assert({ + given: 'a canceled subscription on an un-downgradable guest', + should: 'end as a real downgrade rather than a refusal', + actual: outcome, + expected: { outcome: 'downgraded', publishedAppId: 'app_1', tierChanged: true }, + }); + }); + + it('does NOT force a resize on an app that is still paying', async () => { + // The resize is enforcement. An entitled subscription whose tier change is + // refused for any other reason must never lose its guest as a side effect. + const stopApp = vi.fn(async () => ({ stopped: true })); + mockDb.__state.rows = [{ ...APP, tier: 'dedicated', guestPreset: 'shared-cpu-4x-4096', status: 'running' }]; + + await syncAppTierToSubscription({ publishedAppId: 'app_1', status: 'active' }, deps({ stopApp })); + + expect(stopApp, 'a paying app must never be stopped by a sync').not.toHaveBeenCalled(); + }); +}); + +describe('the default stopApp binding', () => { + /** + * The MAPPING is the part that can silently break, and every other test in this + * file injects its own `stopApp` — so without these the translation from + * `stopPublishedApp`'s outcomes to "is the machine actually down" is unproven, + * and a mapping that answered `stopped: true` to everything would pass the whole + * suite while resizing running machines. + */ + it('reports a real stop as stopped', async () => { + mockStopPublishedApp.mockResolvedValueOnce({ outcome: 'stopped', status: 'stopped', billedSeconds: 0 }); + assert({ + given: 'a machine Fly actually stopped', + should: 'report it down', + actual: await defaultDedicatedTierDeps.stopApp('app_1'), + expected: { stopped: true }, + }); + }); + + it('counts an already-stopped app as stopped', async () => { + mockStopPublishedApp.mockResolvedValueOnce({ outcome: 'refused', reason: 'not_running' }); + assert({ + given: 'an app that was already down', + should: 'report it down — there is no machine left to end', + actual: await defaultDedicatedTierDeps.stopApp('app_1'), + expected: { stopped: true }, + }); + }); + + it('reports a Fly refusal as NOT stopped, carrying the reason', async () => { + mockStopPublishedApp.mockResolvedValueOnce({ outcome: 'stop_failed', error: 'flaps 503' }); + assert({ + given: 'a stop Fly refused', + should: 'report the machine may still be running', + actual: await defaultDedicatedTierDeps.stopApp('app_1'), + expected: { stopped: false, error: 'flaps 503' }, + }); + }); + + it('reports a busy advisory lock as NOT stopped', async () => { + // `lock_busy` means NOTHING was read, stopped or billed — the machine is + // certainly still running. + mockStopPublishedApp.mockResolvedValueOnce({ outcome: 'lock_busy' }); + assert({ + given: 'a run that could not take the meter lock', + should: 'report the machine still running', + actual: await defaultDedicatedTierDeps.stopApp('app_1'), + expected: { stopped: false, error: 'lock_busy' }, + }); + }); + + it('reports any other refusal as NOT stopped', async () => { + mockStopPublishedApp.mockResolvedValueOnce({ outcome: 'refused', reason: 'not_found' }); + assert({ + given: 'a refusal that is not "already down"', + should: 'not claim the machine is down', + actual: await defaultDedicatedTierDeps.stopApp('app_1'), + expected: { stopped: false, error: 'refused' }, + }); + }); +}); diff --git a/packages/lib/src/services/app-hosting/__tests__/dedicated-tier.test.ts b/packages/lib/src/services/app-hosting/__tests__/dedicated-tier.test.ts new file mode 100644 index 0000000000..bd46e09ed7 --- /dev/null +++ b/packages/lib/src/services/app-hosting/__tests__/dedicated-tier.test.ts @@ -0,0 +1,639 @@ +import { describe, it } from 'vitest'; +import { assert } from '../../sandbox/__tests__/riteway'; +import { + DEDICATED_MIN_MACHINES_RUNNING, + DEDICATED_SUBSCRIPTION_KIND, + DEFAULT_GUEST_PRESET, + METERED_MIN_MACHINES_RUNNING, + PUBLISHED_APP_GUEST_PRESETS, + applyMinMachinesRunning, + classifySubscriptionKind, + dedicatedMonthlyFloorCents, + findGuestPreset, + guestPresetShape, + guestPresetsForTier, + isCreditMetered, + isDedicatedEntitled, + isGuestPresetAllowedForTier, + isIdleReaperExempt, + isTerminalSubscriptionStatus, + minMachinesRunningFor, + planSubscriptionMirrorWrite, + planTierChange, +} from '../dedicated-tier'; + +describe('isCreditMetered', () => { + it('is true for metered and false for dedicated', () => { + assert({ + given: 'the metered tier', + should: 'be charged per awake-second against credits', + actual: isCreditMetered('metered'), + expected: true, + }); + assert({ + given: 'the dedicated tier', + should: 'not be charged per awake-second — it pays a flat monthly price', + actual: isCreditMetered('dedicated'), + expected: false, + }); + }); +}); + +describe('isIdleReaperExempt', () => { + it('exempts dedicated only', () => { + assert({ + given: 'a dedicated app', + should: 'be exempt from the idle reaper — always-on is what was bought', + actual: isIdleReaperExempt('dedicated'), + expected: true, + }); + assert({ + given: 'a metered app', + should: 'be reapable — scale-to-zero is the metered product', + actual: isIdleReaperExempt('metered'), + expected: false, + }); + }); +}); + +describe('minMachinesRunningFor', () => { + it('keeps exactly one machine up for dedicated and none for metered', () => { + assert({ + given: 'a dedicated app', + should: 'keep one machine running', + actual: minMachinesRunningFor('dedicated'), + expected: DEDICATED_MIN_MACHINES_RUNNING, + }); + assert({ + given: 'a metered app', + should: 'be allowed to scale to zero', + actual: minMachinesRunningFor('metered'), + expected: METERED_MIN_MACHINES_RUNNING, + }); + }); + + it('never scales a dedicated app beyond one machine', () => { + // Always-on is not replication: the flat price is derived from ONE guest, so a + // second machine doubles the substrate cost the price was set against. + assert({ + given: 'the dedicated always-on setting', + should: 'be exactly one machine', + actual: DEDICATED_MIN_MACHINES_RUNNING, + expected: 1, + }); + }); +}); + +describe('the guest preset catalogue', () => { + it('lets both tiers run the small v1 guest', () => { + assert({ + given: 'the default preset on the metered tier', + should: 'be allowed — it is the v1 unit-economics guardrail', + actual: isGuestPresetAllowedForTier(DEFAULT_GUEST_PRESET, 'metered'), + expected: true, + }); + assert({ + given: 'the default preset on the dedicated tier', + should: 'be allowed', + actual: isGuestPresetAllowedForTier(DEFAULT_GUEST_PRESET, 'dedicated'), + expected: true, + }); + }); + + it('confines the metered tier to the small guest', () => { + // The awake meter prices every second at ONE fixed shape, so a metered app on + // a bigger guest is under-billed by exactly the difference — silently. + const bigger = PUBLISHED_APP_GUEST_PRESETS.filter((p) => p.name !== DEFAULT_GUEST_PRESET); + assert({ + given: 'every preset larger than the v1 guest', + should: 'be refused on the metered tier', + actual: bigger.map((p) => isGuestPresetAllowedForTier(p.name, 'metered')), + expected: bigger.map(() => false), + }); + assert({ + given: 'every preset larger than the v1 guest', + should: 'be allowed on the dedicated tier', + actual: bigger.map((p) => isGuestPresetAllowedForTier(p.name, 'dedicated')), + expected: bigger.map(() => true), + }); + }); + + it('refuses an unknown preset rather than defaulting it in', () => { + assert({ + given: 'a preset that is not in the catalogue', + should: 'be refused on the dedicated tier', + actual: isGuestPresetAllowedForTier('shared-cpu-64x-262144', 'dedicated'), + expected: false, + }); + assert({ + given: 'a preset that is not in the catalogue', + should: 'have no shape to price', + actual: guestPresetShape('shared-cpu-64x-262144'), + expected: null, + }); + assert({ + given: 'a preset that is not in the catalogue', + should: 'not be found', + actual: findGuestPreset('shared-cpu-64x-262144'), + expected: null, + }); + }); + + it('offers the dedicated tier every size and the metered tier only one', () => { + assert({ + given: 'the metered tier', + should: 'offer exactly the v1 guest', + actual: guestPresetsForTier('metered').map((p) => p.name), + expected: [DEFAULT_GUEST_PRESET], + }); + assert({ + given: 'the dedicated tier', + should: 'offer the whole catalogue', + actual: guestPresetsForTier('dedicated').map((p) => p.name), + expected: PUBLISHED_APP_GUEST_PRESETS.map((p) => p.name), + }); + }); +}); + +describe('dedicatedMonthlyFloorCents', () => { + it('prices a bigger guest above a smaller one', () => { + const small = dedicatedMonthlyFloorCents('shared-cpu-1x-512'); + const large = dedicatedMonthlyFloorCents('shared-cpu-4x-4096'); + assert({ + given: 'a 4x/4GB guest against a 1x/512MB guest', + should: 'have a strictly higher monthly floor', + actual: large > small, + expected: true, + }); + }); + + it('is derived from the rate table x 730 hours x the 1.5x machine markup', () => { + // Pinned against the arithmetic rather than a magic number so a change to + // MACHINE_RATES or MACHINE_MARKUP_BPS moves the expectation with the code. + const hourly = 1 * 0.07 + 0.5 * 0.04375; + const expected = Math.ceil(hourly * 730 * 100 * 1.5); + assert({ + given: 'the v1 small guest at the published Sprites rates', + should: 'floor at 1.5x its monthly substrate cost', + actual: dedicatedMonthlyFloorCents('shared-cpu-1x-512'), + expected, + }); + }); + + it('has no opinion about a shape it cannot price', () => { + assert({ + given: 'a preset outside the catalogue', + should: 'yield no floor rather than an invented one', + actual: dedicatedMonthlyFloorCents('shared-cpu-64x-262144'), + expected: 0, + }); + }); +}); + +describe('classifySubscriptionKind', () => { + it('treats a subscription with no kind as an account plan', () => { + // FAIL CLOSED MEANS THE OLD BEHAVIOUR: every subscription written before this + // discriminator existed carries no metadata and must keep its existing path. + assert({ + given: 'no metadata at all', + should: 'be an account plan', + actual: classifySubscriptionKind(undefined), + expected: 'account_plan', + }); + assert({ + given: 'null metadata', + should: 'be an account plan', + actual: classifySubscriptionKind(null), + expected: 'account_plan', + }); + assert({ + given: 'metadata with other keys but no kind', + should: 'be an account plan', + actual: classifySubscriptionKind({ userId: 'user_1' }), + expected: 'account_plan', + }); + assert({ + given: 'an empty-string kind', + should: 'be an account plan rather than an unrecognised value', + actual: classifySubscriptionKind({ kind: '' }), + expected: 'account_plan', + }); + }); + + it('recognises the dedicated hosting kind exactly', () => { + assert({ + given: 'the dedicated hosting kind', + should: 'route to the hosting handler', + actual: classifySubscriptionKind({ kind: DEDICATED_SUBSCRIPTION_KIND }), + expected: 'published_app_dedicated', + }); + }); + + it('reports an unrecognised kind instead of guessing at it', () => { + // Distinguished from `account_plan` only so the caller can LOG it; both take + // the account path, because dropping the event would silently stop + // maintaining a real customer's tier. + assert({ + given: 'a kind this code has never been taught', + should: 'be reported as unknown', + actual: classifySubscriptionKind({ kind: 'published_app_dedicated_v2' }), + expected: 'unknown', + }); + assert({ + given: 'a near-miss on the dedicated kind', + should: 'not be treated as dedicated', + actual: classifySubscriptionKind({ kind: 'PUBLISHED_APP_DEDICATED' }), + expected: 'unknown', + }); + }); +}); + +describe('isDedicatedEntitled', () => { + it('entitles a paying subscription', () => { + assert({ + given: 'the paying statuses', + should: 'entitle the app to stay always-on', + actual: ['active', 'trialing'].map(isDedicatedEntitled), + expected: [true, true], + }); + }); + + it('keeps a past_due app up while Stripe retries', () => { + // Taking a customer's PRODUCTION app to scale-to-zero over a card that will + // probably work on the next attempt is an outage they did not cause. The free + // ride is bounded by Stripe's dunning ending at canceled/unpaid. + assert({ + given: 'a past_due subscription mid-dunning', + should: 'still entitle the app', + actual: isDedicatedEntitled('past_due'), + expected: true, + }); + }); + + it('does not entitle a subscription that was never paid for', () => { + // Otherwise an always-on machine is available to anyone who starts a checkout + // and abandons it. + assert({ + given: 'an incomplete subscription', + should: 'not entitle the app', + actual: isDedicatedEntitled('incomplete'), + expected: false, + }); + }); + + it('does not entitle a subscription that has stopped paying', () => { + assert({ + given: 'terminal non-paying statuses', + should: 'not entitle the app', + actual: ['canceled', 'unpaid', 'incomplete', 'incomplete_expired'].map(isDedicatedEntitled), + expected: [false, false, false, false], + }); + }); +}); + +describe('planTierChange', () => { + it('un-parks an upgrade, because the database cannot represent a parked dedicated row', () => { + const plan = planTierChange({ + from: 'metered', + to: 'dedicated', + status: 'parked', + guestPreset: DEFAULT_GUEST_PRESET, + }); + assert({ + given: 'an out-of-credits parked app being upgraded', + should: 'move to stopped in the same write — parked is metered-only', + actual: plan, + expected: { allowed: true, tier: 'dedicated', unpark: true, nextStatus: 'stopped' }, + }); + }); + + it('un-parks to stopped rather than running — un-parking is not waking', () => { + const plan = planTierChange({ + from: 'metered', + to: 'dedicated', + status: 'parked', + guestPreset: DEFAULT_GUEST_PRESET, + }); + assert({ + given: 'an upgrade from parked', + should: 'leave the app to resume through the ordinary wake path', + actual: plan.allowed === true ? plan.nextStatus : null, + expected: 'stopped', + }); + }); + + it('leaves a non-parked status alone', () => { + const plan = planTierChange({ + from: 'metered', + to: 'dedicated', + status: 'running', + guestPreset: DEFAULT_GUEST_PRESET, + }); + assert({ + given: 'a running app being upgraded', + should: 'change the tier without touching the status', + actual: plan, + expected: { allowed: true, tier: 'dedicated', unpark: false, nextStatus: 'running' }, + }); + }); + + it('refuses a downgrade that would strand the app on an unmeterable guest', () => { + const plan = planTierChange({ + from: 'dedicated', + to: 'metered', + status: 'running', + guestPreset: 'shared-cpu-4x-4096', + }); + assert({ + given: 'a dedicated app on a big guest being downgraded', + should: 'refuse rather than silently resize a serving machine', + actual: plan, + expected: { allowed: false, reason: 'guest_preset_not_allowed' }, + }); + }); + + it('allows a downgrade that is already on the metered-legal guest', () => { + const plan = planTierChange({ + from: 'dedicated', + to: 'metered', + status: 'running', + guestPreset: DEFAULT_GUEST_PRESET, + }); + assert({ + given: 'a dedicated app already on the small guest', + should: 'downgrade cleanly', + actual: plan, + expected: { allowed: true, tier: 'metered', unpark: false, nextStatus: 'running' }, + }); + }); + + it('refuses a no-op and a terminal row', () => { + assert({ + given: 'a tier change to the tier the app is already on', + should: 'be refused as a no-op', + actual: planTierChange({ from: 'metered', to: 'metered', status: 'running', guestPreset: DEFAULT_GUEST_PRESET }), + expected: { allowed: false, reason: 'same_tier' }, + }); + assert({ + given: 'an app being torn down', + should: 'refuse a tier change — the row is about to be deleted', + actual: planTierChange({ from: 'metered', to: 'dedicated', status: 'destroying', guestPreset: DEFAULT_GUEST_PRESET }), + expected: { allowed: false, reason: 'terminal_status' }, + }); + assert({ + given: 'a failed app', + should: 'refuse a tier change — it re-enters only through an explicit retry', + actual: planTierChange({ from: 'metered', to: 'dedicated', status: 'failed', guestPreset: DEFAULT_GUEST_PRESET }), + expected: { allowed: false, reason: 'terminal_status' }, + }); + }); +}); + +describe('applyMinMachinesRunning', () => { + it('preserves every field Fly gave us, including ones this repo has never heard of', () => { + // Fly's machine update is a FULL REPLACE: any field dropped here is deleted + // from the live machine with a 200 OK and no warning. + const current = { + image: 'registry.fly.io/pgs-app-x@sha256:abc', + guest: { cpu_kind: 'shared', cpus: 1, memory_mb: 512 }, + env: { PORT: '8080' }, + mounts: [{ volume: 'vol_1' }], + checks: { http: { type: 'http' } }, + metadata: { pagespace_published_app_id: 'app_1' }, + some_future_fly_field: { we: 'have never heard of this' }, + services: [{ protocol: 'tcp', internal_port: 8080, min_machines_running: 0 }], + }; + const merged = applyMinMachinesRunning(current, 1); + assert({ + given: 'a live config with unknown keys', + should: 'return every key untouched except min_machines_running', + actual: { ...merged.config, services: undefined }, + expected: { ...current, services: undefined }, + }); + assert({ + given: 'a service carrying the old setting', + should: 'carry the new one and keep its other fields', + actual: merged.config.services, + expected: [{ protocol: 'tcp', internal_port: 8080, min_machines_running: 1 }], + }); + assert({ + given: 'one rewritten service', + should: 'be counted', + actual: merged.applied, + expected: 1, + }); + }); + + it('does not mutate the config it was handed', () => { + const current = { services: [{ internal_port: 8080, min_machines_running: 0 }] }; + applyMinMachinesRunning(current, 1); + assert({ + given: 'the caller’s original config', + should: 'be left exactly as it was', + actual: current.services[0].min_machines_running, + expected: 0, + }); + }); + + it('invents nothing for a config with no services', () => { + // Fabricating a service would tell Fly to start routing traffic to a port + // this function has no way to know. + const merged = applyMinMachinesRunning({ image: 'x' }, 1); + assert({ + given: 'a config with no services key', + should: 'come back unchanged', + actual: merged.config, + expected: { image: 'x' }, + }); + assert({ + given: 'a config with no services key', + should: 'report that nothing was applied', + actual: merged.applied, + expected: 0, + }); + }); + + it('passes an entry it does not understand through untouched and uncounted', () => { + const merged = applyMinMachinesRunning({ services: ['weird', null, { internal_port: 8080 }] }, 1); + assert({ + given: 'a services array with entries that are not objects', + should: 'return them exactly as received and rewrite only the real one', + actual: merged.config.services, + expected: ['weird', null, { internal_port: 8080, min_machines_running: 1 }], + }); + assert({ + given: 'entries that could not carry the setting', + should: 'not be counted as applied', + actual: merged.applied, + expected: 1, + }); + }); +}); + +describe('planSubscriptionMirrorWrite', () => { + const at = (iso: string) => new Date(iso); + const row = (over: Partial<{ stripeSubscriptionId: string; status: string; stripeEventCreated: Date | null }> = {}) => ({ + stripeSubscriptionId: 'sub_1', + status: 'active', + stripeEventCreated: at('2026-08-25T12:00:00Z'), + ...over, + }); + + it('applies the first write, when there is nothing to order against', () => { + assert({ + given: 'no existing mirror row', + should: 'apply', + actual: planSubscriptionMirrorWrite({ + existing: null, + incomingSubscriptionId: 'sub_1', + incomingEventCreated: at('2026-08-25T12:00:00Z'), + }), + expected: { apply: true }, + }); + }); + + describe('terminal statuses are absorbing — the half that needs no clock', () => { + it('refuses a late event for a subscription that has already ended', () => { + // Stripe does not order deliveries: an `updated` carrying `active` can arrive + // AFTER the `deleted` that ended the subscription. Applied, it re-entitles + // the app FOREVER — nothing further will arrive to correct it, because the + // subscription is already dead. + for (const status of ['canceled', 'unpaid', 'incomplete_expired']) { + assert({ + given: `a late event against a ${status} row`, + should: 'be refused as absorbed', + actual: planSubscriptionMirrorWrite({ + existing: row({ status }), + incomingSubscriptionId: 'sub_1', + // NEWER than the stored stamp, so only the terminal rule can refuse it. + incomingEventCreated: at('2026-08-25T13:00:00Z'), + }), + expected: { apply: false, reason: 'terminal_absorbed' }, + }); + } + }); + + it('refuses even a same-second event, which the stamp cannot', () => { + // `event.created` has ONE-SECOND resolution, so a `deleted` and an `updated` + // emitted a few hundred milliseconds apart compare as EQUAL and the stamp + // guard admits the later-arriving one either way. This is why both halves + // ship: the terminal rule does not compare times at all. + const sameSecond = at('2026-08-25T12:00:00Z'); + assert({ + given: 'an event in the same second as the cancellation', + should: 'still be refused', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'canceled', stripeEventCreated: sameSecond }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: sameSecond, + }), + expected: { apply: false, reason: 'terminal_absorbed' }, + }); + }); + + it('lets a NEW subscription re-entitle the app', () => { + // A different subscription id is an actual new purchase — the only + // re-entitlement path, and the same model the purchase gate uses. + assert({ + given: 'a different subscription id against a canceled row', + should: 'apply, because re-buying is how an app becomes dedicated again', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'canceled' }), + incomingSubscriptionId: 'sub_2', + incomingEventCreated: at('2026-08-25T13:00:00Z'), + }), + expected: { apply: true }, + }); + }); + + it('does not absorb a row that is merely not paying', () => { + // `past_due` is mid-dunning and can still recover; absorbing it would freeze + // an app that is about to be paid for. + assert({ + given: 'a past_due row', + should: 'accept a newer event', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'past_due' }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: at('2026-08-25T13:00:00Z'), + }), + expected: { apply: true }, + }); + }); + }); + + describe('the monotonic stamp — the half that orders everything else', () => { + it('refuses an event older than the one already applied', () => { + // A stale `past_due` overwriting a fresh `active` is not covered by the + // terminal rule at all. + assert({ + given: 'an event from before the stored one', + should: 'be refused as stale', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'active' }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: at('2026-08-25T11:00:00Z'), + }), + expected: { apply: false, reason: 'stale_event' }, + }); + }); + + it('applies an event at or after the stored stamp', () => { + for (const iso of ['2026-08-25T12:00:00Z', '2026-08-25T12:00:01Z']) { + assert({ + given: `an event stamped ${iso}`, + should: 'apply', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'active' }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: at(iso), + }), + expected: { apply: true }, + }); + } + }); + + it('treats a missing stamp on either side as unknown order and applies', () => { + // The purchase path writes the first row with no event behind it. Refusing + // every subsequent event against an unstamped row would freeze the mirror at + // its creation state; the terminal rule is what keeps that direction safe. + assert({ + given: 'a stored row with no stamp', + should: 'apply', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'incomplete', stripeEventCreated: null }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: at('2026-08-25T11:00:00Z'), + }), + expected: { apply: true }, + }); + assert({ + given: 'an incoming event with no stamp', + should: 'apply', + actual: planSubscriptionMirrorWrite({ + existing: row({ status: 'active' }), + incomingSubscriptionId: 'sub_1', + incomingEventCreated: null, + }), + expected: { apply: true }, + }); + }); + }); +}); + +describe('isTerminalSubscriptionStatus', () => { + it('names the statuses Stripe never returns from', () => { + assert({ + given: 'the terminal statuses', + should: 'be absorbing', + actual: ['canceled', 'unpaid', 'incomplete_expired'].map(isTerminalSubscriptionStatus), + expected: [true, true, true], + }); + assert({ + given: 'statuses a subscription can still leave', + should: 'not be absorbing', + actual: ['active', 'trialing', 'past_due', 'incomplete'].map(isTerminalSubscriptionStatus), + expected: [false, false, false, false], + }); + }); +}); diff --git a/packages/lib/src/services/app-hosting/app-lifecycle-metering.ts b/packages/lib/src/services/app-hosting/app-lifecycle-metering.ts index 127a47e577..a02e6632f1 100644 --- a/packages/lib/src/services/app-hosting/app-lifecycle-metering.ts +++ b/packages/lib/src/services/app-hosting/app-lifecycle-metering.ts @@ -59,6 +59,7 @@ import { utcDayOf, } from './app-metering-core'; import { planTransition } from './provisioner-core'; +import { isCreditMetered } from './dedicated-tier'; export interface AppLifecycleMeteringDeps { isEnabled: () => boolean; @@ -202,17 +203,34 @@ export async function wakePublishedApp( return { outcome: 'parked', reason: DAILY_CAP_PARK_REASON }; } - const payerId = await deps.billing.resolvePayerId({ driveId: row.driveId }); - // No fallback, by design: an app is drive-owned, and `published_apps.ownerId` - // is a denormalized cascade handle, not an answer to "who pays". Billing a - // machine to somebody who may not own the drive is a money movement that cannot - // be taken back; refusing the wake costs one request a parked page. - if (!payerId) return { outcome: 'refused', reason: 'unresolved_payer' }; - - const gate = await deps.billing.gate({ payerId }); - if (!gate.allowed) { - await parkPublishedApp(row, gate.reason ?? 'insufficient_credits'); - return { outcome: 'parked', reason: gate.reason ?? 'insufficient_credits' }; + // THE GATE IS METERED-TIER ONLY, and skipping it for `dedicated` is the whole + // of that SKU's wake path rather than an optimization. + // + // A dedicated app is paid for by a flat monthly subscription, so a credit + // balance has nothing to say about whether it may run. Running the gate anyway + // would be worse than pointless: an exhausted payer's dedicated app would be + // refused a wake and then sent to `parkPublishedApp`, which the status machine + // correctly REFUSES (`parked_is_metered_only`) — leaving an app that is neither + // woken nor parked, that logs a warning on every request, and that the customer + // is paying for. It would also place a credit hold nothing settles. + // + // The payer is still resolved for a metered app, and only for one: the lookup + // exists to answer "who is charged", and nobody is charged per-second here. + let holdId: string | undefined; + if (isCreditMetered(row.tier)) { + const payerId = await deps.billing.resolvePayerId({ driveId: row.driveId }); + // No fallback, by design: an app is drive-owned, and `published_apps.ownerId` + // is a denormalized cascade handle, not an answer to "who pays". Billing a + // machine to somebody who may not own the drive is a money movement that cannot + // be taken back; refusing the wake costs one request a parked page. + if (!payerId) return { outcome: 'refused', reason: 'unresolved_payer' }; + + const gate = await deps.billing.gate({ payerId }); + if (!gate.allowed) { + await parkPublishedApp(row, gate.reason ?? 'insufficient_credits'); + return { outcome: 'parked', reason: gate.reason ?? 'insufficient_credits' }; + } + holdId = gate.holdId; } // A previous stop whose final settle did not land left its window on the row. @@ -227,8 +245,9 @@ export async function wakePublishedApp( } catch (error) { // Nothing started, so nothing may be billed. Release the reservation rather // than leaving it to expire — a stranded hold suppresses the payer's own - // spendable balance for the whole hold TTL. - if (gate.holdId) await deps.billing.releaseHold(gate.holdId); + // spendable balance for the whole hold TTL. A dedicated wake placed no hold, + // so there is nothing to return. + if (holdId) await deps.billing.releaseHold(holdId); return { outcome: 'start_failed', error: error instanceof Error ? error.message : String(error) }; } @@ -246,9 +265,20 @@ export async function wakePublishedApp( .update(publishedApps) .set({ status: 'running', + // The BOUNDARY stamp, written for both tiers: it is what the weekly + // `fly_instance_up` reconcile compares against, and a dedicated app's awake + // time is just as worth reconciling as a metered one's — we simply do not + // charge for it. lastWakeAt: wokenAt, - awakeBilledThrough: wokenAt, - awakeHoldId: gate.holdId ?? null, + // The BILLING watermark, and only a metered app has one. NULL means "no + // awake window is open", which for a dedicated app is the literal truth: + // nothing accrues, nothing settles, nothing closes it. Stamping it anyway + // would leave every dedicated row carrying an open window that no meter + // will ever read — a number that looks like an unbilled liability and is + // not one, on the row an operator reads first when hosting revenue looks + // wrong. + awakeBilledThrough: isCreditMetered(row.tier) ? wokenAt : null, + awakeHoldId: holdId ?? null, }) .where(and(eq(publishedApps.id, row.id), eq(publishedApps.status, row.status))) .returning(); @@ -256,10 +286,10 @@ export async function wakePublishedApp( if (!updated) { // Someone else won the race and owns the window now. Our hold reserves // against a window we will never settle, so release it. - if (gate.holdId) await deps.billing.releaseHold(gate.holdId); + if (holdId) await deps.billing.releaseHold(holdId); return { outcome: 'refused', reason: 'not_wakeable' }; } - return { outcome: 'woken', app: updated, holdId: gate.holdId }; + return { outcome: 'woken', app: updated, holdId }; } /** diff --git a/packages/lib/src/services/app-hosting/app-metering-core.ts b/packages/lib/src/services/app-hosting/app-metering-core.ts index 990b343413..ca7f1c8861 100644 --- a/packages/lib/src/services/app-hosting/app-metering-core.ts +++ b/packages/lib/src/services/app-hosting/app-metering-core.ts @@ -32,6 +32,10 @@ * per tick. That is deliberate: capping cumulatively would mean silently deciding * not to bill a genuinely long-running app, which is the product working as sold. */ + +import type { PublishedAppTier } from '@pagespace/db/schema/published-apps'; +import { isIdleReaperExempt } from './dedicated-tier'; + export const MAX_AWAKE_SETTLE_SPAN_MS = 24 * 60 * 60 * 1000; /** Seconds, from a millisecond span. Negative and non-finite spans price to 0, never to a negative charge. */ @@ -264,8 +268,16 @@ export function utcDayOf(now: Date): string { /** One row's daily awake budget, as the cap decision sees it. */ export interface DailyAwakeCapInput { - /** `published_apps.tier`. Only 'metered' is capped — see the decision below. */ - tier: string; + /** + * `published_apps.tier`. Only 'metered' is capped — see the decision below. + * + * Typed as the enum rather than `string` so the exemption can be asked through + * the shared `isIdleReaperExempt` predicate. A widened `string` here would have + * forced that predicate to widen too, and it is the same question the idle + * reaper asks about whether an app may be switched off — a place where "any + * string" is exactly the wrong domain. + */ + tier: PublishedAppTier; /** `published_apps.awakeSecondsDay` — the day the counter covers, or null. */ counterDay: string | null; /** `published_apps.awakeSecondsToday`. */ @@ -295,7 +307,11 @@ export function planDailyAwakeCap(input: DailyAwakeCapInput): { exceeded: boolea const secondsToday = input.counterDay === input.today && Number.isFinite(input.secondsToday) ? Math.max(0, input.secondsToday) : 0; - if (input.tier !== 'metered') return { exceeded: false, secondsToday }; + // Asked through the shared predicate rather than an inline `!== 'metered'`, so + // "which tier is exempt from being switched off" is stated once for the reaper, + // the cap and anything that comes next — three comparisons that must never drift + // apart, and that all still compile if one of them silently does. + if (isIdleReaperExempt(input.tier)) return { exceeded: false, secondsToday }; if (!Number.isFinite(input.capSeconds) || input.capSeconds <= 0) return { exceeded: false, secondsToday }; return { exceeded: secondsToday >= input.capSeconds, secondsToday }; } diff --git a/packages/lib/src/services/app-hosting/awake-meter.ts b/packages/lib/src/services/app-hosting/awake-meter.ts index fc520461d3..5cebd5ccff 100644 --- a/packages/lib/src/services/app-hosting/awake-meter.ts +++ b/packages/lib/src/services/app-hosting/awake-meter.ts @@ -24,6 +24,14 @@ * source's own failure is reported as a value. One app's bad state must not stop * the fleet from billing. * + * METERED APPS ONLY. The dedicated tier buys a flat monthly price, so there is + * no per-second charge for this meter to make and no balance for its re-gate to + * consult; the filter lives in `listRunningApps` and the reasoning is there. + * (The weekly `fly_instance_up` reconcile still covers BOTH tiers, because it + * compares our mirrored boundaries against Fly's — not our billing watermark + * against anything — so it stays a meaningful check on a machine we are not + * charging for.) + * * Dark behind `APP_HOSTING_ENABLED`: a disabled deployment reports `disabled` and * reads nothing. */ @@ -67,7 +75,19 @@ export type AwakeWatermarkOutcome = 'advanced' | 'superseded'; export interface AwakeMeterDeps { isEnabled: () => boolean; billing: AppBillingDeps; - /** Every app believed AWAKE. `running` is that belief; the repair step is what checks it. */ + /** + * Every METERED app believed AWAKE. `running` is that belief; the repair step + * is what checks it. + * + * Metered only, and the filter belongs in the row SOURCE rather than in a skip + * inside the loop: a dedicated app is paid for by a flat monthly subscription, + * so there is nothing here to bill it for, no hold to re-place, and no + * insolvency it could be parked for (`parked_is_metered_only` makes that row + * unrepresentable, so the park would be refused and retried every single tick + * forever). Filtering at the source also keeps `processed` an honest count of + * what this meter is responsible for instead of a number padded with rows it + * always skips. + */ listRunningApps: () => Promise; /** The mirror's latest stop boundary strictly after `since` — the repair signal. */ findStopBoundary: (machineId: string, since: Date, now: Date) => Promise; @@ -111,7 +131,10 @@ export const defaultAwakeMeterDeps: AwakeMeterDeps = { billing: defaultAppBillingDeps, async listRunningApps() { - return db.select().from(publishedApps).where(eq(publishedApps.status, 'running')); + return db + .select() + .from(publishedApps) + .where(and(eq(publishedApps.status, 'running'), eq(publishedApps.tier, 'metered'))); }, findStopBoundary: (machineId, since, now) => findStopBoundarySince(machineId, since, now), diff --git a/packages/lib/src/services/app-hosting/build-core.ts b/packages/lib/src/services/app-hosting/build-core.ts index b34eee2890..27c4a98a73 100644 --- a/packages/lib/src/services/app-hosting/build-core.ts +++ b/packages/lib/src/services/app-hosting/build-core.ts @@ -15,6 +15,8 @@ */ import type { MachineConfig, MachineGuest } from '../fly/flaps-client'; +import type { PublishedAppTier } from '@pagespace/db/schema/published-apps'; +import { findGuestPreset, minMachinesRunningFor } from './dedicated-tier'; import type { PublishedAppStatus } from '@pagespace/db/schema/published-apps'; /** @@ -101,14 +103,24 @@ export function machineNameForDigest(digest: string): string { } /** - * The guest sizing for a preset. Returns null for an unknown preset rather than - * throwing or defaulting — defaulting would silently create a machine of a size - * the unit-economics guardrail never approved, and the `published_apps` - * `guestPreset` CHECK exists precisely so that set stays deliberate. + * The guest sizing for a preset, read from the one catalogue + * ({@link PUBLISHED_APP_GUEST_PRESETS}) that also drives pricing and the + * `published_apps_guest_preset_allowed` CHECK. + * + * Returns null for an unknown preset rather than throwing or defaulting — + * defaulting would silently create a machine of a size the unit-economics + * guardrail never approved, and would do it with a name that says otherwise. + * + * `memory_mb` is derived from the catalogue's GB figure and rounded, because the + * catalogue is denominated in GB (that is what the rate table prices) while Fly's + * API takes megabytes. Every catalogue entry is a whole number of MB today; the + * rounding is there so that a future half-measure lands on an integer Fly will + * accept instead of being rejected as a malformed guest. */ export function guestForPreset(preset: string): MachineGuest | null { - if (preset === 'shared-cpu-1x-512') return { cpu_kind: 'shared', cpus: 1, memory_mb: 512 }; - return null; + const entry = findGuestPreset(preset); + if (entry === null) return null; + return { cpu_kind: 'shared', cpus: entry.cpus, memory_mb: Math.round(entry.memoryGB * 1024) }; } export interface BuildMachineConfigInput { @@ -116,6 +128,15 @@ export interface BuildMachineConfigInput { digest: string; guestPreset: string; publishedAppId: string; + /** + * The app's billing tier — the ONLY thing that differs between the two + * products' machine configs, and it differs in exactly one field + * ({@link minMachinesRunningFor}). Required rather than defaulted: a config + * built without knowing the tier would build the METERED shape, which for a + * dedicated app means a machine that scales to zero while the customer pays a + * flat monthly price to keep it up — a silent failure of the whole SKU. + */ + tier: PublishedAppTier; /** Extra environment for the app process. `PORT` is set by us and cannot be overridden. */ env?: Record; } @@ -136,6 +157,13 @@ export interface BuildMachineConfigInput { * scaled-to-zero app at all), but only our own idle reaper stops it, so the * stop timestamp we bill against is an API call we made rather than a behaviour we * inferred. + * + * `min_machines_running` is the ONLY field the tier changes. For a metered app it + * is 0 — scale-to-zero is the product, and an app that never sleeps never stops + * costing credits. For a dedicated app it is 1: Fly's proxy keeps one machine up, + * which is what "always on" is bought for. `autostart` stays true on both, because + * it is also what lets the proxy recover a dedicated machine that Fly stopped for + * its own reasons (a host migration, an OOM) without waiting for our next tick. */ export function buildMachineConfig(input: BuildMachineConfigInput): MachineConfig | null { const guest = guestForPreset(input.guestPreset); @@ -161,7 +189,7 @@ export function buildMachineConfig(input: BuildMachineConfigInput): MachineConfi ], autostart: true, autostop: 'off', - min_machines_running: 0, + min_machines_running: minMachinesRunningFor(input.tier), }, ], metadata: { diff --git a/packages/lib/src/services/app-hosting/build-job.ts b/packages/lib/src/services/app-hosting/build-job.ts index f34a677f42..d1bf2bd1b2 100644 --- a/packages/lib/src/services/app-hosting/build-job.ts +++ b/packages/lib/src/services/app-hosting/build-job.ts @@ -206,6 +206,10 @@ export async function runAppBuildJob( digest: built.digest, guestPreset: app.guestPreset, publishedAppId: app.id, + // Read from the row, never assumed: the tier decides `min_machines_running`, + // and a deploy that guessed it would quietly re-create a paid always-on app as + // scale-to-zero on its very next build. + tier: app.tier, }); if (config === null) { return await failBuild(deps, app, previousDigest, previousSize, 'deploy_failed', diff --git a/packages/lib/src/services/app-hosting/dedicated-tier-service.ts b/packages/lib/src/services/app-hosting/dedicated-tier-service.ts new file mode 100644 index 0000000000..2741f82c67 --- /dev/null +++ b/packages/lib/src/services/app-hosting/dedicated-tier-service.ts @@ -0,0 +1,757 @@ +/** + * dedicated-tier-service — the imperative shell for the DEDICATED tier: moving an + * app between tiers, pushing the always-on setting to its live machine, and + * mirroring the Stripe subscription that pays for it. + * + * Every rule about what those facts MEAN lives in the pure `dedicated-tier.ts` + * next door; this file only reads state, calls those functions, and persists the + * result — the same split as `provisioner` / `provisioner-core`. + * + * NOTHING HERE TALKS TO STRIPE. Stripe lives in `apps/web` (the client, the + * webhook, the price catalogue), and hosting is consumed by `apps/web`, + * `apps/processor` and `apps/realtime` alike, so a Stripe import here would drag a + * secret-bearing SDK into two services that must never have one. The web app + * creates or cancels the subscription and hands the resulting FACTS to + * {@link recordDedicatedSubscription}; this module is the mirror, not the caller. + * + * Dark behind `APP_HOSTING_ENABLED`, checked before any read — and separately, + * the tier is a no-op on a deployment where `isBillingEnabled()` is false + * (tenant, onprem): see {@link isDedicatedTierPurchasable}. + */ + +import { and, eq } from '@pagespace/db/operators'; +import { db } from '@pagespace/db/db'; +import { publishedApps, type PublishedApp, type PublishedAppTier } from '@pagespace/db/schema/published-apps'; +import { + publishedAppSubscriptions, + type PublishedAppSubscription, +} from '@pagespace/db/schema/published-app-subscriptions'; +import { loggers } from '../../logging/logger-config'; +import { isBillingEnabled } from '../../deployment-mode'; +import { stopPublishedApp } from './app-lifecycle-metering'; +import { releaseHold as releaseCreditHold } from '../../billing/credit-consume'; +import { isAppHostingEnabled, resolveFlyMachinesToken } from './app-hosting-env'; +import { + updateMachineConfig, + type FlapsTransport, + type MachineConfig, +} from '../fly/flaps-client'; +import { + applyMinMachinesRunning, + isDedicatedEntitled, + DEFAULT_GUEST_PRESET, + isCreditMetered, + minMachinesRunningFor, + planSubscriptionMirrorWrite, + planTierChange, + type TierChangeRefusal, +} from './dedicated-tier'; + +/** + * Whether the dedicated tier can be BOUGHT on this deployment. + * + * False on tenant and onprem, where `isBillingEnabled()` is false. On those + * deployments hosting is unlimited by design and there is no Stripe customer to + * charge, so "buy always-on" is not a thing that can happen — and the honest + * shape of that is a flag that refuses, not a Stripe call that fails. Every entry + * point that would move money checks this BEFORE it looks at anything else, so a + * billing-disabled deployment makes no Stripe call at all rather than making one + * that errors. + * + * Note what this does NOT gate: an app whose `tier` column already says + * `dedicated` keeps behaving as dedicated everywhere (always-on, reaper-exempt, + * no credit drain) on every deployment. The tier is a runtime BEHAVIOUR that + * happens to be sold; only the selling needs billing. + */ +export function isDedicatedTierPurchasable(): boolean { + return isAppHostingEnabled() && isBillingEnabled(); +} + +export interface DedicatedTierDeps { + isEnabled: () => boolean; + /** + * Push a merged config to a live machine. Bound to `updateMachineConfig`, the + * only sanctioned mutation path — a partial update silently deletes a serving + * app's `services`. + */ + updateMachineConfig: ( + flyAppName: string, + machineId: string, + mergeFn: (current: MachineConfig) => MachineConfig, + ) => Promise; + /** + * Return a credit reservation without billing it — used when an UPGRADE closes + * an awake window that nothing will ever settle. See + * {@link setPublishedAppTier}. + */ + releaseHold: (holdId: string) => Promise; + /** + * Stop an app's machine, reporting whether the machine is actually DOWN. + * + * A boolean rather than a throw, because `stopPublishedApp` never throws: it + * reports every refusal as a value (`stop_failed`, `lock_busy`, `not_running`). + * A caller that wrapped it in try/catch would therefore catch nothing and sail + * past a machine that is still running — which is exactly the failure this + * whole path exists to prevent, so the contract states the answer instead of + * hiding it in an exception that never arrives. + */ + stopApp: (publishedAppId: string) => Promise<{ stopped: boolean; error?: string }>; +} + +function defaultTransport(): FlapsTransport { + return { token: resolveFlyMachinesToken() }; +} + +export const defaultDedicatedTierDeps: DedicatedTierDeps = { + isEnabled: isAppHostingEnabled, + async updateMachineConfig(flyAppName, machineId, mergeFn) { + await updateMachineConfig(defaultTransport(), flyAppName, machineId, mergeFn); + }, + releaseHold: (holdId) => releaseCreditHold(holdId), + async stopApp(publishedAppId) { + // `operator`, not `insolvent`: `insolvent` lands in `parked`, and `parked` is + // metered-only at the database — the app is still `dedicated` at this point, + // so that transition would be refused and the machine would stay up. + const result = await stopPublishedApp(publishedAppId, 'operator'); + if (result.outcome === 'stopped') return { stopped: true }; + // ALREADY DOWN counts as stopped. There is no machine left to end, and + // treating it as a failure would block the resize on the one case where the + // resize is safest. + if (result.outcome === 'refused' && result.reason === 'not_running') return { stopped: true }; + // Everything else leaves a machine that may still be RUNNING: Fly refused the + // stop, or the awake meter's advisory lock was held so nothing was read or + // stopped at all. + return { + stopped: false, + error: result.outcome === 'stop_failed' ? result.error : result.outcome, + }; + }, +}; + +export interface SetTierOptions { + /** + * Move the app back to the default guest in the SAME statement as the tier. + * + * Enforcement only. A resize destroys and recreates the machine on the next + * deploy (a guest change is a machine CREATE — the rootfs assembles there), so + * doing it as a side effect of an ordinary billing event would take a live app + * down over a card decline. {@link enforceUnpaidDedicated} is the one caller, + * and it stops the app FIRST so there is nothing live to interrupt. + */ + resetGuestPreset?: boolean; + /** Replace `lastError` with a plain-language reason the publish surface can show. */ + lastError?: string | null; +} + +export type SetTierResult = + | { + ok: true; + app: PublishedApp; + /** + * Whether the live machine's `min_machines_running` was updated to match the + * new tier. + * + * FALSE IS NOT A FAILURE of the tier change, and the two are deliberately + * reported separately. The database row is the source of truth for what the + * customer bought; the machine config is a projection of it that the next + * build re-derives from scratch anyway (`buildMachineConfig` reads the tier). + * Rolling the tier back because Fly was briefly unreachable would undo a + * paid-for upgrade over a transient network error — so the row commits, the + * push is attempted, and a failure is reported for {@link syncDedicatedMachineConfig} + * (or the next deploy) to repair. + */ + machineConfigSynced: boolean; + machineConfigError?: string; + } + | { ok: false; reason: TierChangeRefusal | 'disabled' | 'not_found' }; + +/** + * Move a published app between tiers. + * + * THE TIER AND THE STATUS MOVE IN ONE STATEMENT, because for the most important + * case they must. An upgrade from a PARKED metered app has to un-park it in the + * same write: `published_apps_parked_is_metered_only` makes a parked dedicated row + * unrepresentable, so "set the tier, then fix the status" is two statements of + * which the first cannot commit. That the constraint forces the behaviour the + * product wants — paying the flat price is how an out-of-credits app serves again + * — is a happy accident, but the write has to be built knowing it. + * + * The app lands in `stopped`, not `running`: un-parking is not waking. It resumes + * through the ordinary wake path on its next request, which is the only path that + * records an awake boundary. + * + * The row is locked for the read so the decision is made against state that cannot + * change underneath it, exactly as `transitionPublishedApp` does — a tier change + * racing the metering cron produces one winner and one refusal rather than two + * writes where the second silently overwrites a decision the first had validated. + */ +export async function setPublishedAppTier( + publishedAppId: string, + to: PublishedAppTier, + deps: DedicatedTierDeps = defaultDedicatedTierDeps, + options: SetTierOptions = {}, +): Promise { + if (!deps.isEnabled()) return { ok: false, reason: 'disabled' }; + + const written = await db.transaction(async (tx) => { + const [row] = await tx + .select() + .from(publishedApps) + .where(eq(publishedApps.id, publishedAppId)) + .limit(1) + .for('update'); + if (!row) return { ok: false as const, reason: 'not_found' as const }; + + // The guest the app will be running AFTER this write. Normally its current + // one; on the enforcement path the default, because the two columns have to + // move together — `published_apps_metered_guest_preset` makes a metered row on + // a larger guest unrepresentable, so "set the tier, then resize" is two + // statements of which the first cannot commit. + const nextGuestPreset = options.resetGuestPreset ? DEFAULT_GUEST_PRESET : row.guestPreset; + + const plan = planTierChange({ + from: row.tier, + to, + status: row.status, + guestPreset: nextGuestPreset, + }); + if (!plan.allowed) return { ok: false as const, reason: plan.reason }; + + // CLOSING THE AWAKE WINDOW IS PART OF THE UPGRADE, not a separate cleanup. + // + // An app upgraded WHILE AWAKE carries an open metering window and the credit + // HOLD the wake placed. The moment its tier changes, the awake meter stops + // listing it — so nothing will ever settle that window or release that hold, + // and the reservation would suppress the payer's spendable balance for its + // whole TTL against a charge that is never coming. So the window is closed in + // the same statement as the tier, and the hold is returned after the commit. + // + // The accrued-but-unbilled seconds since the last settle are FORGIVEN rather + // than settled. That is the deliberate direction: the customer has just moved + // to a flat price, the amount is at most one heartbeat interval on the fixed + // v1 guest, and settling it here would mean putting a charge in the middle of + // an upgrade path — money moving in a function whose job is a column write. + const closesWindow = !isCreditMetered(plan.tier); + const heldId = closesWindow ? row.awakeHoldId : null; + + const [updated] = await tx + .update(publishedApps) + .set({ + tier: plan.tier, + status: plan.nextStatus, + // The park reason is cleared with the park. Leaving it behind would make + // the publish surface — which reads this column as "why is my app not + // serving" — keep telling a customer who just paid that they are out of + // credits. + ...(plan.unpark ? { lastError: null } : {}), + ...(closesWindow ? { awakeBilledThrough: null, awakeHoldId: null } : {}), + ...(nextGuestPreset === row.guestPreset ? {} : { guestPreset: nextGuestPreset }), + ...(options.lastError === undefined ? {} : { lastError: options.lastError }), + }) + // Guarded on the tier we planned against, not on the id alone: two tier + // changes racing (a webhook cancelling while a user upgrades) must produce + // one winner, and the loser must see `not_found` rather than overwrite a + // decision made against state it never read. + .where(and(eq(publishedApps.id, publishedAppId), eq(publishedApps.tier, row.tier))) + .returning(); + if (!updated) return { ok: false as const, reason: 'not_found' as const }; + return { ok: true as const, app: updated, releasedHoldId: heldId }; + }); + + if (!written.ok) return written; + + // AFTER the commit, never inside it: `releaseHold` is a separate write on the + // ledger, and a Stripe-facing tier change must not hold a `published_apps` row + // lock open across it. A failure here is logged and swallowed — the hold expires + // on its own TTL, whereas letting it throw would report a committed upgrade as a + // failure and invite a retry that finds `same_tier`. + if (written.releasedHoldId) { + try { + await deps.releaseHold(written.releasedHoldId); + } catch (error) { + loggers.ai.warn('Dedicated upgrade could not return the awake hold; it will expire on its TTL', { + publishedAppId: written.app.id, + holdId: written.releasedHoldId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const sync = await pushMinMachinesRunning(written.app, deps); + return { + ok: true, + app: written.app, + machineConfigSynced: sync.ok, + ...(sync.ok ? {} : { machineConfigError: sync.error }), + }; +} + +/** + * Make a live machine's `min_machines_running` agree with its app's tier. + * + * Separately callable so a tier change whose push failed can be repaired without + * re-running the billing decision — the row already says what the customer bought, + * and this only makes Fly agree with it. + * + * Goes through `updateMachineConfig`, the fetch-merge-send path, and through it + * ALONE. Fly's machine update is a full replace: a partial post deletes the + * `services` an app is serving traffic through, `mounts`, and `checks`, with a + * 200 OK. The merge callback spreads the config it is handed + * ({@link applyMinMachinesRunning}), so every field — including ones this repo has + * never heard of — survives. + */ +export async function syncDedicatedMachineConfig( + publishedAppId: string, + deps: DedicatedTierDeps = defaultDedicatedTierDeps, +): Promise<{ ok: boolean; error?: string }> { + if (!deps.isEnabled()) return { ok: false, error: 'disabled' }; + const [row] = await db + .select() + .from(publishedApps) + .where(eq(publishedApps.id, publishedAppId)) + .limit(1); + if (!row) return { ok: false, error: 'not_found' }; + return pushMinMachinesRunning(row, deps); +} + +async function pushMinMachinesRunning( + app: PublishedApp, + deps: DedicatedTierDeps, +): Promise<{ ok: boolean; error?: string }> { + // No machine yet (never deployed, or mid blue/green swap) is a SUCCESS, not a + // failure: there is no config to correct, and the machine the next deploy + // creates is built from the tier by `buildMachineConfig`. Reporting a failure + // here would make every upgrade of an un-deployed app look broken. + if (!app.machineId) return { ok: true }; + + const desired = minMachinesRunningFor(app.tier); + try { + await deps.updateMachineConfig(app.flyAppName, app.machineId, (current) => { + const merged = applyMinMachinesRunning(current, desired); + if (merged.applied === 0) { + // The machine has no services to carry the setting. Worth saying out loud: + // a published app's machine is created WITH a service (that is how the + // router's replay reaches it), so a config without one is a machine that + // is not serving traffic at all — a fact the operator wants, and one this + // function would otherwise hide behind a successful no-op update. + loggers.ai.warn('Published app machine has no services to carry min_machines_running', { + publishedAppId: app.id, + flyAppName: app.flyAppName, + machineId: app.machineId, + }); + } + return merged.config; + }); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + loggers.ai.error( + 'Published app min_machines_running could not be pushed to Fly — the tier row is correct and the machine is not', + error instanceof Error ? error : new Error(message), + { publishedAppId: app.id, flyAppName: app.flyAppName, tier: app.tier, desired }, + ); + return { ok: false, error: message }; + } +} + +// ── The Stripe mirror ──────────────────────────────────────────────────────── + +/** The facts `apps/web` reads off a Stripe subscription and hands to the mirror. */ +export interface DedicatedSubscriptionFacts { + publishedAppId: string; + userId: string; + stripeSubscriptionId: string; + stripePriceId: string; + guestPreset: string; + status: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelAtPeriodEnd: boolean; + /** + * `event.created` of the Stripe event these facts came from, or null when they + * came from an API response rather than a webhook (the purchase path). Used + * only to ORDER writes — see {@link planSubscriptionMirrorWrite}. + */ + stripeEventCreated?: Date | null; +} + +/** + * What the mirror write did, and the row that is now authoritative. + * + * `row` is returned on EVERY outcome, including the refusals, and that is the + * point rather than a convenience: a caller that has just been told its event was + * stale still needs to know what the mirror actually says, because the tier must + * follow the STORED status rather than the status of an event we just declined to + * believe. Returning only on success would leave the caller re-entitling an app + * from the very event the guard rejected. + */ +export interface MirrorWriteResult { + outcome: 'applied' | 'stale_event' | 'terminal_absorbed'; + row: PublishedAppSubscription | null; +} + +/** + * Record (or update) the subscription paying for an app's dedicated tier. + * + * Upserts on `publishedAppId` rather than on `stripeSubscriptionId`, and the + * choice matters: an app has at most ONE dedicated subscription, so a second + * subscription id arriving for the same app means the first was replaced (a + * cancel-and-resubscribe), and the row should follow the app rather than + * accumulate one row per subscription the app has ever had. Conflicting on the + * subscription id instead would leave the stale row in place and make + * "is this app paid for" ambiguous. + * + * Returns null when hosting is dark, so the mirror is inert on a deployment where + * the feature does not exist. + */ +export async function recordDedicatedSubscription( + facts: DedicatedSubscriptionFacts, +): Promise { + if (!isAppHostingEnabled()) return { outcome: 'applied', row: null }; + + const incomingEventCreated = facts.stripeEventCreated ?? null; + + return db.transaction(async (tx) => { + // Locked for the read so the decision is made against state that cannot change + // underneath it. Two Stripe deliveries racing the same subscription is exactly + // the situation this guard exists for, so reading without the lock would leave + // the ordering decision itself subject to the reordering it is meant to refuse. + const [existing] = await tx + .select() + .from(publishedAppSubscriptions) + .where(eq(publishedAppSubscriptions.publishedAppId, facts.publishedAppId)) + .limit(1) + .for('update'); + + const plan = planSubscriptionMirrorWrite({ + existing: existing + ? { + stripeSubscriptionId: existing.stripeSubscriptionId, + status: existing.status, + stripeEventCreated: existing.stripeEventCreated, + } + : null, + incomingSubscriptionId: facts.stripeSubscriptionId, + incomingEventCreated, + }); + + if (!plan.apply) { + loggers.ai.warn('Dedicated subscription mirror refused an out-of-order Stripe event', { + publishedAppId: facts.publishedAppId, + stripeSubscriptionId: facts.stripeSubscriptionId, + incomingStatus: facts.status, + storedStatus: existing?.status, + reason: plan.reason, + }); + return { outcome: plan.reason, row: existing ?? null }; + } + + const values = { + publishedAppId: facts.publishedAppId, + userId: facts.userId, + stripeSubscriptionId: facts.stripeSubscriptionId, + stripePriceId: facts.stripePriceId, + guestPreset: facts.guestPreset, + status: facts.status, + currentPeriodStart: facts.currentPeriodStart, + currentPeriodEnd: facts.currentPeriodEnd, + cancelAtPeriodEnd: facts.cancelAtPeriodEnd, + stripeEventCreated: incomingEventCreated, + }; + + const [row] = await tx + .insert(publishedAppSubscriptions) + .values(values) + .onConflictDoUpdate({ + // An app has at most ONE dedicated subscription, so a second subscription + // id for the same app means the first was replaced (a cancel-and-rebuy) and + // the row should follow the app. Conflicting on the subscription id instead + // would leave the stale row in place and make "is this app paid for" + // ambiguous. + target: publishedAppSubscriptions.publishedAppId, + set: { ...values, updatedAt: new Date() }, + }) + .returning(); + return { outcome: 'applied' as const, row: row ?? null }; + }); +} + +/** + * The subscription mirror row for one Stripe subscription id — the webhook's + * lookup, which knows a subscription and needs the app. + */ +export async function findDedicatedSubscriptionByStripeId( + stripeSubscriptionId: string, +): Promise { + const [row] = await db + .select() + .from(publishedAppSubscriptions) + .where(eq(publishedAppSubscriptions.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1); + return row ?? null; +} + +/** The subscription mirror row for one app — the publish surface's lookup. */ +export async function findDedicatedSubscriptionForApp( + publishedAppId: string, +): Promise { + const [row] = await db + .select() + .from(publishedAppSubscriptions) + .where(eq(publishedAppSubscriptions.publishedAppId, publishedAppId)) + .limit(1); + return row ?? null; +} + +export type DedicatedSubscriptionSyncOutcome = + /** The subscription entitles its app to be dedicated, and the app now is. */ + | { outcome: 'entitled'; publishedAppId: string; tierChanged: boolean } + /** The subscription no longer pays, and the app was moved back to metered. */ + | { outcome: 'downgraded'; publishedAppId: string; tierChanged: boolean } + /** Nothing local points at this subscription — see the note in the function. */ + | { outcome: 'unknown_subscription' } + /** Entitlement changed but the tier could not follow. Reported, never thrown. */ + | { outcome: 'tier_change_refused'; publishedAppId: string; reason: string }; + +/** + * Make an app's tier follow its subscription's status — the webhook's whole job + * once it has the facts. + * + * TAKES THE MIRROR ROW, NEVER AN EVENT'S STATUS, and that is a correctness + * property rather than an interface preference. `recordDedicatedSubscription` can + * REFUSE an out-of-order event (a late `active` after a cancellation, a stale + * update), and the mirror then holds the status we still believe. Syncing from the + * event's own status instead would re-entitle an app from the very message the + * ordering guard had just rejected — the guard would refuse the write and the tier + * would move anyway, which is worse than having no guard, because it looks + * defended. Passing the row makes that mistake unexpressible. + * + * ENTITLEMENT IS A STATUS QUESTION, not an existence one: a `canceled` or `unpaid` + * subscription still leaves a mirror row, and the row is what lets us say WHY an + * app went back to metered. So the tier is derived from + * {@link isDedicatedEntitled} on the stored status rather than from whether a row + * is present. + * + * A DOWNGRADE CAN LEGITIMATELY FAIL, and this reports rather than forces it. An + * app on a bigger guest cannot be metered (`published_apps_metered_guest_preset` + * — the awake meter prices one fixed shape and would under-bill it), so a + * customer who stops paying for a 4x app leaves an app that cannot go back to the + * metered tier as it stands. Forcing it by silently resizing would destroy and + * recreate the machine — an outage as a side effect of a billing event, and one + * the customer never asked for. The refusal is returned so the caller can act on + * it deliberately (stop the app, or resize it and retry); what must not happen is + * a webhook quietly taking a live app down. + */ +export async function syncAppTierToSubscription( + mirror: Pick | null, + deps: DedicatedTierDeps = defaultDedicatedTierDeps, +): Promise { + if (!mirror) { + // Not an error, and deliberately not a throw. Stripe redelivers events, and a + // subscription this deployment has no app for (one created against another + // environment's database — the common test-mode case) would otherwise be + // retried forever against a row that is never going to appear. + return { outcome: 'unknown_subscription' }; + } + + const entitled = isDedicatedEntitled(mirror.status); + const target: PublishedAppTier = entitled ? 'dedicated' : 'metered'; + const result = await setPublishedAppTier(mirror.publishedAppId, target, deps); + + if (!result.ok) { + // `same_tier` is the ordinary case — most subscription events do not change + // entitlement at all (a renewal, a payment method update), and reporting each + // of those as a refusal would bury the ones that matter. + if (result.reason === 'same_tier') { + return entitled + ? { outcome: 'entitled', publishedAppId: mirror.publishedAppId, tierChanged: false } + : { outcome: 'downgraded', publishedAppId: mirror.publishedAppId, tierChanged: false }; + } + // A DOWNGRADE THAT CANNOT HAPPEN IS NOT A DOWNGRADE. If the app is running a + // guest the metered tier may not run, the plain tier change is refused — and + // leaving it there would mean an app that nobody is paying for keeps its + // always-on configuration, stays out of BOTH meters, and does so forever, + // because no further Stripe event is coming for a dead subscription. Enforce + // it instead. + if (!entitled && result.reason === 'guest_preset_not_allowed') { + return enforceUnpaidDedicated(mirror.publishedAppId, deps); + } + + loggers.ai.warn('Published app tier could not follow its dedicated subscription', { + publishedAppId: mirror.publishedAppId, + status: mirror.status, + target, + reason: result.reason, + }); + return { outcome: 'tier_change_refused', publishedAppId: mirror.publishedAppId, reason: result.reason }; + } + + return entitled + ? { outcome: 'entitled', publishedAppId: mirror.publishedAppId, tierChanged: true } + : { outcome: 'downgraded', publishedAppId: mirror.publishedAppId, tierChanged: true }; +} + +// ── Dunning visibility ─────────────────────────────────────────────────────── + +/** + * How long a dedicated app may serve on an unpaid subscription before the fact + * becomes an operator-visible signal, in days. + * + * NOT an enforcement threshold — nothing is switched off at 7 days, and this + * constant deliberately gives no code the power to. It exists because the choice + * to keep a `past_due` app always-on (see `DEDICATED_ENTITLED_STATUSES`) trades a + * customer-facing outage for a bounded free ride, and that bound is a STRIPE + * ACCOUNT SETTING rather than anything this repo controls: dunning configured to + * leave failed subscriptions `past_due` indefinitely would serve a dedicated + * machine free forever, and the only reason we would ever find out is if + * something counted. This is that something. + * + * Seven days because that is comfortably past a normal Stripe retry schedule (a + * card that is going to work has worked by then), so a row over this line means + * dunning is not converging rather than that it is still in progress. + */ +export const DEDICATED_DUNNING_VISIBILITY_DAYS = 7; + +export interface DedicatedDunningSurvey { + /** Dedicated subscriptions currently in Stripe's retry window. */ + pastDue: number; + /** Of those, the ones overdue past {@link DEDICATED_DUNNING_VISIBILITY_DAYS} — the number that matters. */ + pastDueStale: number; + /** The app ids behind `pastDueStale`, so the signal names its subjects rather than only counting them. */ + staleAppIds: string[]; +} + +/** + * Count the dedicated apps being served on an unpaid subscription. + * + * Measured from `currentPeriodEnd`, which is when the money became due: Stripe + * does not advance a subscription's period while a renewal is unpaid, so the gap + * between that timestamp and now IS how long the app has been served for free. + * (`updatedAt` would be wrong — every retry attempt writes it, so an actively + * failing subscription would look freshly-touched forever.) + * + * Returns zeroes rather than throwing when hosting is dark, so the caller's cron + * stays green on a deployment where the feature does not exist. + */ +export async function surveyDedicatedDunning( + now: Date = new Date(), +): Promise { + if (!isAppHostingEnabled()) return { pastDue: 0, pastDueStale: 0, staleAppIds: [] }; + + const rows = await db + .select({ + publishedAppId: publishedAppSubscriptions.publishedAppId, + currentPeriodEnd: publishedAppSubscriptions.currentPeriodEnd, + }) + .from(publishedAppSubscriptions) + .where(eq(publishedAppSubscriptions.status, 'past_due')); + + const cutoff = new Date(now.getTime() - DEDICATED_DUNNING_VISIBILITY_DAYS * 24 * 60 * 60 * 1000); + const stale = rows.filter((row) => row.currentPeriodEnd.getTime() < cutoff.getTime()); + return { + pastDue: rows.length, + pastDueStale: stale.length, + // Bounded so a pathological fleet cannot turn a log line into a payload. The + // COUNT above is the complete figure; this is the sample an operator starts + // from. + staleAppIds: stale.slice(0, 20).map((row) => row.publishedAppId), + }; +} + +/** + * The `lastError` an enforced downgrade writes — the ONE user-facing explanation + * of why an app came off the dedicated tier and shrank. + * + * Carried on the row rather than raised as a notification for the same reason the + * daily-cap park is: `published_apps.lastError` is the column the publish surface + * already reads as "why is my app not serving", and adding a notification type is + * user-visible UI work inside a branch whose safety property is that it ships dark. + */ +export const UNPAID_DOWNGRADE_REASON = + 'dedicated hosting ended: the app was stopped and returned to the standard machine size'; + +/** + * Take an app off the dedicated tier when its subscription has stopped paying and + * the ordinary downgrade cannot be expressed. + * + * ───────────────────────────────────────────────────────────────────────────── + * THE SITUATION. A dedicated app may run a guest the metered tier is forbidden + * (the awake meter prices one fixed shape, so a metered row on a larger guest is + * under-billed silently — `published_apps_metered_guest_preset` makes it + * unrepresentable). When such an app's subscription ends, `setPublishedAppTier` + * correctly refuses. Stopping there is the trap: the row stays `dedicated`, so it + * keeps `min_machines_running: 1`, stays out of the awake meter AND the rootfs + * storage drain, and does it INDEFINITELY — the subscription is dead, so no + * further event will ever arrive to reconsider. An unpaid machine, running + * forever, invisible to both meters. + * + * THE ORDER IS THE WHOLE DESIGN, and each step is where it is because of what the + * previous one makes safe: + * + * 1. STOP the machine. This is what actually ends the cost, and it is first so + * that everything after it happens to an app that is already down. `operator` + * rather than `insolvent`, because `insolvent` lands in `parked` and `parked` + * is metered-only — the app is still `dedicated` here, so that transition + * would be refused and the machine would stay up. + * 2. TIER AND GUEST TOGETHER. Both columns move in one statement, because + * neither shape is legal alone. The resize is the only part a customer did + * not ask for, and it is defensible precisely because step 1 already stopped + * the app: no live machine is interrupted, and the smaller guest is what the + * next wake creates rather than something that happens to a serving app. + * Nothing is lost with it — a published app's machine has no volume; its + * filesystem comes from the image. + * 3. PUSH `min_machines_running: 0`. Without this the row says metered while the + * LIVE machine config still says keep-one-up, and Fly's proxy would restart + * the machine we just stopped. The row alone does not reach Fly. + * + * A failure at step 1 ABORTS: resizing an app we could not stop would leave a + * running machine whose row promises a guest it is not on, and would still be + * always-on. Better to leave the state consistent, report it, and let the next + * event or an operator retry. + * ───────────────────────────────────────────────────────────────────────────── + */ +export async function enforceUnpaidDedicated( + publishedAppId: string, + deps: DedicatedTierDeps = defaultDedicatedTierDeps, +): Promise { + let stop: { stopped: boolean; error?: string }; + try { + stop = await deps.stopApp(publishedAppId); + } catch (error) { + // The default binding reports failures as values, but a deps implementation + // (or the transport under it) can still throw, and a throw here must not + // escape into the webhook as a 500 for an app we have merely failed to tidy. + stop = { stopped: false, error: error instanceof Error ? error.message : String(error) }; + } + if (!stop.stopped) { + loggers.ai.error( + 'Unpaid dedicated app could not be stopped; leaving it on the dedicated tier rather than resizing a running machine', + new Error(stop.error ?? 'stop refused'), + { publishedAppId, error: stop.error }, + ); + return { outcome: 'tier_change_refused', publishedAppId, reason: 'stop_failed' }; + } + + const result = await setPublishedAppTier(publishedAppId, 'metered', deps, { + resetGuestPreset: true, + lastError: UNPAID_DOWNGRADE_REASON, + }); + if (!result.ok) { + // ALREADY METERED IS SUCCESS, not a fault. Stripe redelivers events, so a + // second `deleted` for a subscription already enforced lands here with nothing + // left to do — and a machine that is already stopped and already metered is + // exactly the state this function exists to reach. Reporting it as a refusal + // would raise an operator alert, on every redelivery, for work that is done. + if (result.reason === 'same_tier') { + return { outcome: 'downgraded', publishedAppId, tierChanged: false }; + } + loggers.ai.error( + 'Unpaid dedicated app was stopped but could not be returned to the metered tier', + new Error(result.reason), + { publishedAppId, reason: result.reason }, + ); + return { outcome: 'tier_change_refused', publishedAppId, reason: result.reason }; + } + + return { outcome: 'downgraded', publishedAppId, tierChanged: true }; +} diff --git a/packages/lib/src/services/app-hosting/dedicated-tier.ts b/packages/lib/src/services/app-hosting/dedicated-tier.ts new file mode 100644 index 0000000000..179d41993a --- /dev/null +++ b/packages/lib/src/services/app-hosting/dedicated-tier.ts @@ -0,0 +1,503 @@ +/** + * dedicated-tier — everything the DEDICATED (flat monthly) published-app tier + * decides, as pure functions. + * + * INVARIANT: zero I/O. No db, no fetch, no clock. Same arrangement, and the same + * reason, as `provisioner-core` and `app-metering-core` beside it — the tier is a + * billing product, and a billing product's rules should be exhaustively testable + * without a database or a Stripe account. + * + * ───────────────────────────────────────────────────────────────────────────── + * WHAT THE TIER ACTUALLY IS. `metered` and `dedicated` run the SAME pipeline: + * same provisioner, same builder, same blue/green deploy, same router. Four + * things differ, and they are all here: + * + * 1. NO BALANCE GATE. A dedicated app is paid for by a flat monthly + * subscription, so there is nothing for a credit balance to say about it. + * `decideAppRoute` already reads the tier for this (`router-core.ts`), and + * {@link isCreditMetered} is the same fact stated once for everybody else. + * 2. NO AWAKE DRAIN. The corollary, and the half that is easy to forget: an + * app that skips the gate must also skip the METER, or the customer pays a + * flat monthly price AND per-awake-second credits for the same machine. + * 3. ALWAYS ON. `min_machines_running: 1` in the machine's service config, so + * Fly's proxy keeps one machine up rather than letting it scale to zero. + * 4. REAPER-EXEMPT. The idle reaper stops apps that stop being hit; stopping a + * dedicated app is stopping the thing the customer is paying for. + * + * The database enforces the one direction that must never be representable: a + * `parked` dedicated row (`published_apps_parked_is_metered_only`). Parking IS + * credit-exhaustion enforcement, and an app with no credit gate cannot have been + * refused by one. Everything else here is policy, and policy lives in code. + * ───────────────────────────────────────────────────────────────────────────── + */ + +import type { PublishedAppStatus, PublishedAppTier } from '@pagespace/db/schema/published-apps'; +import type { MachineConfig } from '../fly/flaps-client'; +import { + calculateDedicatedMonthlyFloorCents, + type MachineShape, +} from '../../monitoring/machine-pricing'; + +/** + * Whether this tier's runtime is charged per awake-second against credits. + * + * The single statement of "who does the credit pipeline apply to", read by the + * wake gate, the heartbeat meter and the router alike. Written as a function of + * the tier rather than as `tier === 'metered'` at four call sites so that adding + * a third tier is one edit here and a type error everywhere it matters, instead + * of four `=== 'metered'` comparisons that all still compile and all now mean + * something subtly wrong. + */ +export function isCreditMetered(tier: PublishedAppTier): boolean { + return tier === 'metered'; +} + +/** + * Whether the idle reaper must leave this app alone. + * + * THE ONLY THING KEEPING A DEDICATED APP ALWAYS-ON IS THIS PREDICATE plus + * {@link DEDICATED_MIN_MACHINES_RUNNING}. The database does not help here: the + * status machine's `running -> stopped` edge is legal for BOTH tiers (only + * `running -> parked` is metered-only), and it has to be — an operator stop and + * a redeploy both need it. So a reaper that forgets to call this will happily + * stop a machine somebody is paying a flat monthly price to keep up, and nothing + * downstream will refuse it. + * + * THREE THINGS ASK THIS QUESTION and they must never drift apart: the idle + * reaper's candidate query, the per-app daily awake cap, and anything that comes + * next. Two of them are JavaScript and call this function; the third is SQL and + * cannot, so it is built from {@link IDLE_REAPER_EXEMPT_TIERS} — the same array + * this function tests against. That is the point of the array existing separately + * from the function: a predicate and a `WHERE` clause that disagree would still + * compile, still pass every test that only exercises one of them, and quietly stop + * a machine somebody is paying a flat monthly price to keep up. + */ +export const IDLE_REAPER_EXEMPT_TIERS: readonly PublishedAppTier[] = ['dedicated']; + +export function isIdleReaperExempt(tier: PublishedAppTier): boolean { + return IDLE_REAPER_EXEMPT_TIERS.includes(tier); +} + +/** + * `min_machines_running` for a dedicated app's service. + * + * ONE, not more: this tier is "always on", not "replicated". A second machine + * doubles the substrate cost the flat price was derived from + * ({@link calculateDedicatedMonthlyFloorCents} prices ONE guest) and buys an + * availability property nobody has sold. Multi-machine is a different SKU. + */ +export const DEDICATED_MIN_MACHINES_RUNNING = 1; + +/** `min_machines_running` for a metered app: scale to zero is the whole product. */ +export const METERED_MIN_MACHINES_RUNNING = 0; + +/** The `min_machines_running` a tier's machine service is configured with. */ +export function minMachinesRunningFor(tier: PublishedAppTier): number { + return tier === 'dedicated' ? DEDICATED_MIN_MACHINES_RUNNING : METERED_MIN_MACHINES_RUNNING; +} + +// ── Guest presets ──────────────────────────────────────────────────────────── + +/** + * A sellable guest size. + * + * `name` is the value stored in `published_apps.guestPreset` and constrained by + * `published_apps_guest_preset_allowed`; the shape is what we ask Fly for and + * what the price floor is derived from. All three are in one record because a + * preset whose stored name, requested shape and priced shape can drift apart is + * a preset that can be sold at one size and run at another. + */ +export interface PublishedAppGuestPreset { + name: string; + cpus: number; + memoryGB: number; + /** + * Which tiers may run this size. + * + * The small guest is the v1 unit-economics guardrail and stays available to + * BOTH tiers. Everything larger is dedicated-only, and that is a deliberate + * economic constraint rather than an arbitrary one: the metered meter prices + * every awake second at {@link PUBLISHED_APP_GUEST_SHAPE}, a single fixed + * shape, so a metered app on a bigger guest would be UNDER-BILLED by exactly + * the difference, silently, for as long as it ran. Sizes are unlocked by + * moving to a tier whose price already accounts for the size. + */ + tiers: readonly PublishedAppTier[]; +} + +const BOTH_TIERS: readonly PublishedAppTier[] = ['metered', 'dedicated']; +const DEDICATED_ONLY: readonly PublishedAppTier[] = ['dedicated']; + +/** + * The sellable guest sizes, smallest first. + * + * This array is the SOURCE the `published_apps_guest_preset_allowed` CHECK + * mirrors — widening it is an additive migration on that constraint, and the + * pair is pinned by a test that writes a rejected preset to a real Postgres. + * + * `shared-cpu-8x-8192` is deliberately absent. It is a five-figure-cents monthly + * SKU on a fixed guest and nobody has asked for it; a size that exists in the + * catalogue is a size a customer can be sold, and an unsold size in a CHECK + * constraint is a promise to support hardware we have never run. + */ +export const PUBLISHED_APP_GUEST_PRESETS: readonly PublishedAppGuestPreset[] = [ + { name: 'shared-cpu-1x-512', cpus: 1, memoryGB: 0.5, tiers: BOTH_TIERS }, + { name: 'shared-cpu-1x-1024', cpus: 1, memoryGB: 1, tiers: DEDICATED_ONLY }, + { name: 'shared-cpu-2x-2048', cpus: 2, memoryGB: 2, tiers: DEDICATED_ONLY }, + { name: 'shared-cpu-4x-4096', cpus: 4, memoryGB: 4, tiers: DEDICATED_ONLY }, +]; + +/** The default (and only metered-legal) preset — the v1 unit-economics guardrail. */ +export const DEFAULT_GUEST_PRESET = 'shared-cpu-1x-512'; + +/** Look a preset up by its stored name. Null for anything not in the catalogue. */ +export function findGuestPreset(name: string): PublishedAppGuestPreset | null { + return PUBLISHED_APP_GUEST_PRESETS.find((preset) => preset.name === name) ?? null; +} + +/** The preset's machine shape, for pricing. Null for an unknown preset. */ +export function guestPresetShape(name: string): MachineShape | null { + const preset = findGuestPreset(name); + return preset === null ? null : { cpus: preset.cpus, memoryGB: preset.memoryGB }; +} + +/** The sizes this tier may run, in catalogue order. */ +export function guestPresetsForTier(tier: PublishedAppTier): PublishedAppGuestPreset[] { + return PUBLISHED_APP_GUEST_PRESETS.filter((preset) => preset.tiers.includes(tier)); +} + +/** + * May this (tier, preset) pair exist? + * + * FALSE for an unknown preset, not true — an unrecognised size must never be + * treated as permitted by default. This mirrors + * `published_apps_metered_guest_preset` in the database, so a pair refused here + * is a refusal VALUE rather than a constraint violation thrown at the caller. + */ +export function isGuestPresetAllowedForTier(name: string, tier: PublishedAppTier): boolean { + const preset = findGuestPreset(name); + return preset !== null && preset.tiers.includes(tier); +} + +/** + * The floor price, in whole cents per month, below which this preset must not be + * sold as dedicated. 0 for an unknown preset — see + * {@link calculateDedicatedMonthlyFloorCents} for why a shape we cannot price + * yields no opinion rather than an invented one; the unknown preset itself is + * refused by {@link isGuestPresetAllowedForTier} before any price is consulted. + */ +export function dedicatedMonthlyFloorCents(name: string): number { + const shape = guestPresetShape(name); + return shape === null ? 0 : calculateDedicatedMonthlyFloorCents(shape); +} + +// ── Stripe subscription routing ────────────────────────────────────────────── + +/** + * The metadata key/value stamped on every dedicated-hosting Stripe subscription. + * + * This pair is the ONLY thing separating a hosting charge from an account plan in + * the webhook, and the separation is load-bearing in both directions: + * + * - a hosting subscription that reached `handleSubscriptionChange` would derive + * an account tier of `free` from its unmapped price and write that over a + * paying customer's tier, permanently (see the docblock on + * `published_app_subscriptions`); + * - an account plan that reached the hosting handler would be mirrored as a + * subscription for an app that does not exist. + * + * `kind` rather than something hosting-specific because it is a discriminator for + * ALL subscription kinds this account may later sell, and a discriminator named + * after its first value ages badly. + */ +export const SUBSCRIPTION_KIND_METADATA_KEY = 'kind'; +export const DEDICATED_SUBSCRIPTION_KIND = 'published_app_dedicated'; + +/** + * What a Stripe subscription's metadata says this subscription IS. + * + * - `account_plan` — no `kind` at all. EVERY subscription that exists today is + * this, which is why the absent case maps here rather than to `unknown`: the + * existing path must stay byte-identical for every subscription written before + * this discriminator existed. Fail-closed means "the old behaviour", not "no + * behaviour". + * - `published_app_dedicated` — an exact match on the value above. + * - `unknown` — a `kind` we do not recognise. The caller LOGS it and then takes + * the account-plan path anyway. Dropping the event instead would be the wrong + * kind of safety: a typo'd or future `kind` on a real account subscription + * would silently stop maintaining that customer's tier, and the tier reconcile + * cron would have nothing to repair from. + */ +export type StripeSubscriptionKind = 'account_plan' | 'published_app_dedicated' | 'unknown'; + +export function classifySubscriptionKind( + metadata: Record | null | undefined, +): StripeSubscriptionKind { + const kind = metadata?.[SUBSCRIPTION_KIND_METADATA_KEY]; + if (kind === undefined || kind === null || kind === '') return 'account_plan'; + if (kind === DEDICATED_SUBSCRIPTION_KIND) return 'published_app_dedicated'; + return 'unknown'; +} + +/** + * Stripe statuses under which a dedicated subscription entitles its app to stay + * always-on. + * + * DELIBERATELY WIDER THAN `ENTITLED_SUBSCRIPTION_STATUSES` in + * `billing/subscription-tier-sync.ts`, and a separate constant rather than a + * reuse, because the two answer different questions and the consequences of + * getting them wrong point in opposite directions. Losing a plan feature for a + * few days while a card is retried is an inconvenience; taking a customer's + * PRODUCTION APP to scale-to-zero is an outage they did not cause and cannot + * predict. So `past_due` keeps the app up. + * + * That is a real, bounded cost — we serve an always-on machine we have not been + * paid for — and the bound is Stripe's dunning settings, which end a failing + * subscription at `canceled` or `unpaid`. BOTH of those fall out of this set, so + * the free ride ends when Stripe says the retries are over. THE BOUND IS + * CONFIGURATION, NOT CODE: a Stripe account whose dunning is set to leave failed + * subscriptions `past_due` indefinitely would serve a dedicated app forever for + * free, and nothing in this repo can detect that. It is the one operator setting + * this constant depends on. + * + * `incomplete` is absent, and that is the other half of the same care: a + * subscription created but never paid for is how somebody would get an always-on + * machine by starting a checkout and abandoning it. + */ +export const DEDICATED_ENTITLED_STATUSES: readonly string[] = ['active', 'trialing', 'past_due']; + +export function isDedicatedEntitled(status: string): boolean { + return DEDICATED_ENTITLED_STATUSES.includes(status); +} + +// ── Tier changes ───────────────────────────────────────────────────────────── + +export type TierChangeRefusal = + /** The app is already on the requested tier. */ + | 'same_tier' + /** The app is being torn down, or has failed; a tier change would be writing to a corpse. */ + | 'terminal_status' + /** The requested tier cannot run the app's current guest size. */ + | 'guest_preset_not_allowed'; + +/** + * What a tier change has to do besides writing the column. + * + * `unpark` is the interesting one and it is the reason this is a plan rather than + * a boolean. A `parked` app is one the credit gate refused, and + * `published_apps_parked_is_metered_only` makes `parked` + `dedicated` an + * unrepresentable row — so an upgrade FROM a parked metered app is not merely + * allowed to un-park it, it MUST, in the same statement, or the database rejects + * the write outright. That is exactly the behaviour the product wants (paying the + * flat price is how you get an out-of-credits app serving again), and it is + * pleasing that the constraint and the product agree; but the write has to be + * built knowing it, because "set the tier, then fix the status" is two statements + * and the first of them cannot commit. + * + * `stopped` rather than `running` because parking is not the same as being awake: + * the app resumes through the ordinary wake path on its next request, exactly as + * `PUBLISHED_APP_TRANSITIONS` allows (`parked -> stopped`, never `parked -> + * running`). + */ +export type TierChangePlan = + | { allowed: true; tier: PublishedAppTier; unpark: boolean; nextStatus: PublishedAppStatus } + | { allowed: false; reason: TierChangeRefusal }; + +export interface TierChangeInput { + from: PublishedAppTier; + to: PublishedAppTier; + status: PublishedAppStatus; + guestPreset: string; +} + +/** + * Decide whether an app may move between tiers, and what the status must become. + * + * Never throws: a refusal is a value, because tier changes are user actions + * racing crons and a refusal is an ordinary outcome. + * + * A DOWNGRADE off a bigger guest is refused rather than silently resized. Resizing + * means destroying and recreating the machine (a guest change is a machine CREATE, + * not an update — the rootfs assembles at create), so a downgrade that quietly did + * it would take a serving app offline as a side effect of a billing action the + * user thought was about money. The caller resizes first, then downgrades, and the + * user sees both steps. + */ +export function planTierChange({ from, to, status, guestPreset }: TierChangeInput): TierChangePlan { + if (from === to) return { allowed: false, reason: 'same_tier' }; + // `destroying` is terminal and `failed` re-enters the pipeline only through an + // explicit provisioning retry; a tier written to either is a tier nothing will + // ever act on, and for `destroying` it is a tier on a row that is about to be + // deleted. + if (status === 'destroying' || status === 'failed') { + return { allowed: false, reason: 'terminal_status' }; + } + if (!isGuestPresetAllowedForTier(guestPreset, to)) { + return { allowed: false, reason: 'guest_preset_not_allowed' }; + } + const unpark = status === 'parked'; + return { allowed: true, tier: to, unpark, nextStatus: unpark ? 'stopped' : status }; +} + +// ── Machine config ─────────────────────────────────────────────────────────── + +/** The result of merging a tier's always-on setting into a live machine config. */ +export interface MinMachinesMergeResult { + config: MachineConfig; + /** + * How many service entries were rewritten. ZERO is the case worth handling: a + * machine with no `services` has no port bindings and is not receiving traffic, + * so there is nowhere for `min_machines_running` to go and the caller has just + * learned something is wrong with the machine rather than with the merge. + */ + applied: number; +} + +/** + * Set `min_machines_running` on every service in a LIVE machine config, returning + * the whole config for {@link updateMachineConfig} to send back. + * + * WHY THIS IS A MERGE AND NOT A CONFIG. Fly's machine update is a FULL REPLACE: + * every field absent from the posted config is deleted from the machine, with a + * 200 OK and no warning. Posting `{services: [...]}` alone would strip `image`, + * `env`, `mounts`, `checks` and `metadata` off an app that is serving traffic + * right now. So this takes the CURRENT config — the one `updateMachineConfig` + * fetched — and returns it with one field changed, preserving unknown keys at both + * levels through the spreads and through `MachineConfig`'s index signature. + * + * Non-object entries in `services` pass through untouched rather than being + * coerced or dropped: `services` is typed `unknown[]` precisely because Fly owns + * its shape, and an entry we do not understand is one we must return exactly as we + * received it. It is also not counted in `applied`, so a config full of entries we + * could not act on reports 0 rather than claiming success. + * + * A config with NO `services` key comes back unchanged (and `applied: 0`) rather + * than acquiring an invented one: fabricating a service would tell Fly to start + * routing traffic to a port this function has no way to know. + */ +export function applyMinMachinesRunning( + current: MachineConfig, + minMachinesRunning: number, +): MinMachinesMergeResult { + const services = current.services; + if (!Array.isArray(services)) return { config: { ...current }, applied: 0 }; + + let applied = 0; + const next = services.map((service) => { + if (typeof service !== 'object' || service === null || Array.isArray(service)) return service; + applied += 1; + return { ...(service as Record), min_machines_running: minMachinesRunning }; + }); + return { config: { ...current, services: next }, applied }; +} + +// ── Mirror-write ordering ──────────────────────────────────────────────────── + +/** + * Statuses from which a Stripe subscription never returns. + * + * Stripe will not move a `canceled`, `unpaid` or `incomplete_expired` + * subscription back to `active` — that path does not exist in its state machine. + * Anything claiming otherwise is a message arriving out of order, which is why + * this set is treated as ABSORBING below rather than merely non-entitling. + */ +export const TERMINAL_SUBSCRIPTION_STATUSES: readonly string[] = [ + 'canceled', + 'unpaid', + 'incomplete_expired', +]; + +export function isTerminalSubscriptionStatus(status: string): boolean { + return TERMINAL_SUBSCRIPTION_STATUSES.includes(status); +} + +/** The mirror row as the ordering decision sees it. */ +export interface MirrorRowState { + stripeSubscriptionId: string; + status: string; + /** + * `event.created` of the Stripe event this row was last written from, or null + * for a row written outside an event (the purchase path). + */ + stripeEventCreated: Date | null; +} + +export type MirrorWriteRefusal = + /** + * The row is terminal and the event names the SAME subscription. Stripe cannot + * revive a dead subscription, so this is a message from before it died. + */ + | 'terminal_absorbed' + /** An event older than the one this row was last written from. */ + | 'stale_event'; + +export type MirrorWritePlan = { apply: true } | { apply: false; reason: MirrorWriteRefusal }; + +/** + * Decide whether a Stripe event may be written into the mirror. + * + * ───────────────────────────────────────────────────────────────────────────── + * WHY THIS EXISTS. **Stripe does not order webhook deliveries.** A + * `customer.subscription.updated` carrying `active` can arrive AFTER the + * `customer.subscription.deleted` that ended the subscription — a redelivery, a + * retry after a timeout, or simply two events racing. Written blindly, that late + * `active` re-entitles the app, and it re-entitles it FOREVER: nothing else will + * ever arrive to correct it, because the subscription is already dead and Stripe + * has no more events to send. The result is an always-on machine nobody is paying + * for, and nothing in the system knows. + * + * TWO GUARDS, and they are here together because neither closes the case alone. + * + * - **Terminal statuses are ABSORBING** — the load-bearing half. Once a row is + * `canceled` / `unpaid` / `incomplete_expired`, the only thing that may + * re-entitle that app is a DIFFERENT `stripeSubscriptionId`, i.e. an actual + * new purchase. This needs no clock at all, which is exactly why it is the + * half that carries the weight, and it matches the purchase model: re-buying + * after a cancellation always mints a new subscription. + * - **Monotonic event stamps** — the general half. `event.created` orders + * everything the terminal rule says nothing about: a stale `past_due` + * overwriting a fresh `active`, an out-of-order pair of ordinary updates. + * + * The stamp cannot replace the terminal rule, and that is the point of shipping + * both: **`event.created` has ONE-SECOND resolution.** Two events in the same + * second are indistinguishable to it, so a `deleted` and an `updated` emitted a + * few hundred milliseconds apart compare as equal and the stamp guard admits the + * later-arriving one either way. The terminal rule has no such blind spot, + * because it does not compare times at all. + * + * A NULL stamp on either side means "unknown order", and the write is ALLOWED — + * the purchase path writes the first row without an event, and refusing every + * subsequent event against an unstamped row would freeze the mirror at its + * creation state. The terminal rule still applies in that case, which is what + * keeps the permissive direction safe. + * ───────────────────────────────────────────────────────────────────────────── + */ +export function planSubscriptionMirrorWrite({ + existing, + incomingSubscriptionId, + incomingEventCreated, +}: { + /** The row already in the mirror, or null when this is the first write. */ + existing: MirrorRowState | null; + incomingSubscriptionId: string; + incomingEventCreated: Date | null; +}): MirrorWritePlan { + if (existing === null) return { apply: true }; + + if ( + isTerminalSubscriptionStatus(existing.status) && + existing.stripeSubscriptionId === incomingSubscriptionId + ) { + return { apply: false, reason: 'terminal_absorbed' }; + } + + if ( + existing.stripeEventCreated !== null && + incomingEventCreated !== null && + incomingEventCreated.getTime() < existing.stripeEventCreated.getTime() + ) { + return { apply: false, reason: 'stale_event' }; + } + + return { apply: true }; +} diff --git a/packages/lib/src/services/app-hosting/idle-reaper.ts b/packages/lib/src/services/app-hosting/idle-reaper.ts index 399791b19d..adc5dddf5d 100644 --- a/packages/lib/src/services/app-hosting/idle-reaper.ts +++ b/packages/lib/src/services/app-hosting/idle-reaper.ts @@ -39,12 +39,13 @@ * reads nothing at all. */ -import { and, eq, isNull, lt, or, sql } from '@pagespace/db/operators'; +import { and, eq, isNull, lt, notInArray, or, sql } from '@pagespace/db/operators'; import { db, getAdvisoryLockPool } from '@pagespace/db/db'; import { withAdvisoryLock, type AdvisoryLockPool } from '@pagespace/db/advisory-lock'; import { publishedApps, type PublishedApp } from '@pagespace/db/schema/published-apps'; import { loggers } from '../../logging/logger-config'; import { isAppHostingEnabled, resolveIdleStopSeconds } from './app-hosting-env'; +import { IDLE_REAPER_EXEMPT_TIERS } from './dedicated-tier'; import { planDailyAwakeCap, planIdleStop, @@ -144,7 +145,15 @@ export const defaultIdleReaperDeps: IdleReaperDeps = { .where( and( eq(publishedApps.status, 'running'), - eq(publishedApps.tier, 'metered'), + // The tier exemption, built from the SAME array `isIdleReaperExempt` + // tests against rather than spelled `= 'metered'` here. A dedicated app + // is paid for by a flat monthly price precisely to stay up, so reaping + // one is stopping the thing the customer bought — and the database will + // not catch the mistake: `running -> stopped` is legal for BOTH tiers + // (only `running -> parked` is metered-only), and it has to be, because + // an operator stop and a redeploy both need that edge. This predicate is + // the only thing standing there. + notInArray(publishedApps.tier, [...IDLE_REAPER_EXEMPT_TIERS]), // BOTH stamps must be older than the cutoff (a NULL stamp counting as // "not recent"), which is the SQL spelling of `planIdleStop`'s "recency // is the LATER of the two". A row with neither stamp satisfies this and diff --git a/packages/lib/src/services/app-hosting/provisioner-core.ts b/packages/lib/src/services/app-hosting/provisioner-core.ts index baa193720f..3b570ab30c 100644 --- a/packages/lib/src/services/app-hosting/provisioner-core.ts +++ b/packages/lib/src/services/app-hosting/provisioner-core.ts @@ -104,6 +104,14 @@ export type StatusInvariantViolation = * CHECK without adding a clause here re-opens exactly the bug this closes — a * transition the pure layer calls legal and the database then throws on, turning a * documented refusal value into an exception that aborts the caller's transaction. + * + * STATUS-COUPLED ONLY, which is why this does not mirror every CHECK on the table. + * `published_apps_metered_guest_preset` (a metered app may only run the v1 small + * guest) couples the TIER to the GUEST and says nothing about status, so a status + * transition can never violate it and asking about it here would mean threading a + * column through this function that no caller of it can change. Its mirror is + * `planTierChange` in `dedicated-tier.ts`, which is the function that CAN violate + * it — the pairing is with whatever moves the coupled columns, not with this file. */ export function checkStatusInvariants( status: PublishedAppStatus, diff --git a/packages/lib/src/services/sandbox/sandbox-storage-billing.ts b/packages/lib/src/services/sandbox/sandbox-storage-billing.ts index c7350ef86f..1065136e82 100644 --- a/packages/lib/src/services/sandbox/sandbox-storage-billing.ts +++ b/packages/lib/src/services/sandbox/sandbox-storage-billing.ts @@ -149,7 +149,7 @@ export const defaultReconcileSandboxStorageDeps: ReconcileSandboxStorageDeps = { }, /** - * The PUBLISHED-APP row source — every app's rootfs, whatever its status. + * The PUBLISHED-APP row source — every METERED app's rootfs, whatever its status. * * No liveness predicate, unlike the two Sprite sources: their filter exists * because a torn-down Sprite holds no filesystem, whereas a published app holds @@ -157,6 +157,15 @@ export const defaultReconcileSandboxStorageDeps: ReconcileSandboxStorageDeps = { * `destroying` rows are the one exclusion — their Fly app is being killed, and * billing a resource we are actively removing bills for our own teardown latency. * + * DEDICATED APPS ARE EXCLUDED, for the same reason the awake meter excludes them: + * that tier buys a FLAT MONTHLY PRICE, and draining credits for its rootfs + * alongside it would charge the customer twice for one machine. The flat price + * absorbs the rootfs cost comfortably — it is derived from CPU and memory at + * 1.5x the Sprites rate table, which for an always-on guest is orders of + * magnitude above the $0.15/GB-month the image actually costs. Filtering here + * rather than skipping inside the loop keeps the meter's counts an honest tally + * of what it is responsible for. + * * `imageSizeMeasuredAt` is NOT NULL exactly when `imageSizeBytes` is (a CHECK * constraint), so the never-measured branch here means precisely "no build has * landed yet" — an app with no image, which genuinely holds no rootfs and @@ -172,7 +181,7 @@ export const defaultReconcileSandboxStorageDeps: ReconcileSandboxStorageDeps = { measuredAt: publishedApps.imageSizeMeasuredAt, }) .from(publishedApps) - .where(ne(publishedApps.status, 'destroying')); + .where(and(ne(publishedApps.status, 'destroying'), eq(publishedApps.tier, 'metered'))); }, lookupDriveOwnerId,