@secondlayer/shared 2.1.0 → 3.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/dist/src/crypto/secrets.js +47 -3
  3. package/dist/src/crypto/secrets.js.map +5 -4
  4. package/dist/src/db/index.d.ts +112 -137
  5. package/dist/src/db/index.js.map +2 -2
  6. package/dist/src/db/jsonb.d.ts +5 -1
  7. package/dist/src/db/jsonb.js.map +2 -2
  8. package/dist/src/db/queries/account-spend-caps.d.ts +444 -0
  9. package/dist/src/db/queries/account-spend-caps.js +60 -0
  10. package/dist/src/db/queries/account-spend-caps.js.map +10 -0
  11. package/dist/src/db/queries/account-usage.d.ts +468 -0
  12. package/dist/src/db/queries/account-usage.js +222 -0
  13. package/dist/src/db/queries/account-usage.js.map +11 -0
  14. package/dist/src/db/queries/accounts.d.ts +100 -109
  15. package/dist/src/db/queries/accounts.js +15 -1
  16. package/dist/src/db/queries/accounts.js.map +3 -3
  17. package/dist/src/db/queries/integrity.d.ts +85 -107
  18. package/dist/src/db/queries/projects.d.ts +87 -109
  19. package/dist/src/db/queries/provisioning-audit.d.ts +85 -107
  20. package/dist/src/db/queries/subgraph-gaps.d.ts +85 -107
  21. package/dist/src/db/queries/subgraphs.d.ts +86 -109
  22. package/dist/src/db/queries/subgraphs.js +2 -3
  23. package/dist/src/db/queries/subgraphs.js.map +4 -4
  24. package/dist/src/db/queries/{workflows.d.ts → tenant-compute-addons.d.ts} +108 -142
  25. package/dist/src/db/queries/tenant-compute-addons.js +47 -0
  26. package/dist/src/db/queries/tenant-compute-addons.js.map +10 -0
  27. package/dist/src/db/queries/tenants.d.ts +98 -110
  28. package/dist/src/db/queries/tenants.js +55 -8
  29. package/dist/src/db/queries/tenants.js.map +6 -5
  30. package/dist/src/db/queries/usage.d.ts +86 -132
  31. package/dist/src/db/queries/usage.js +5 -64
  32. package/dist/src/db/queries/usage.js.map +4 -5
  33. package/dist/src/db/schema.d.ts +107 -136
  34. package/dist/src/errors.d.ts +8 -7
  35. package/dist/src/errors.js +11 -12
  36. package/dist/src/errors.js.map +3 -3
  37. package/dist/src/index.d.ts +119 -143
  38. package/dist/src/index.js +11 -12
  39. package/dist/src/index.js.map +4 -4
  40. package/dist/src/node/local-client.d.ts +85 -107
  41. package/dist/src/pricing.d.ts +20 -1
  42. package/dist/src/pricing.js +58 -1
  43. package/dist/src/pricing.js.map +3 -3
  44. package/migrations/0045_drop_marketplace_columns.ts +47 -0
  45. package/migrations/0046_tenant_activity_signal.ts +47 -0
  46. package/migrations/0047_usage_daily_tenant_id.ts +73 -0
  47. package/migrations/0048_tenant_compute_addons.ts +49 -0
  48. package/migrations/0049_accounts_stripe_customer_id.ts +30 -0
  49. package/migrations/0050_account_spend_caps.ts +45 -0
  50. package/migrations/0051_workflow_ai_usage_daily.ts +40 -0
  51. package/migrations/0052_sentries.ts +61 -0
  52. package/migrations/0053_workflow_runtime.ts +88 -0
  53. package/migrations/0054_accounts_plan_hobby.ts +32 -0
  54. package/migrations/0055_ai_usage_account_scope.ts +108 -0
  55. package/migrations/0056_drop_workflow_sentry_residuals.ts +23 -0
  56. package/migrations/0057_subscriptions.ts +137 -0
  57. package/package.json +26 -14
  58. package/dist/src/db/queries/workflows.js +0 -260
  59. package/dist/src/db/queries/workflows.js.map +0 -12
  60. package/dist/src/lib/plans.d.ts +0 -9
  61. package/dist/src/lib/plans.js +0 -37
  62. package/dist/src/lib/plans.js.map +0 -10
  63. package/dist/src/schemas/workflows.d.ts +0 -70
  64. package/dist/src/schemas/workflows.js +0 -43
  65. package/dist/src/schemas/workflows.js.map +0 -10
@@ -60,10 +60,6 @@ interface SubgraphsTable {
60
60
  handler_code: string | null;
61
61
  source_code: string | null;
62
62
  project_id: string | null;
63
- is_public: Generated<boolean>;
64
- tags: Generated<string[]>;
65
- description: string | null;
66
- forked_from_id: string | null;
67
63
  created_at: Generated<Date>;
68
64
  updated_at: Generated<Date>;
69
65
  }
@@ -98,6 +94,7 @@ interface AccountsTable {
98
94
  bio: string | null;
99
95
  avatar_url: string | null;
100
96
  slug: string | null;
97
+ stripe_customer_id: string | null;
101
98
  created_at: Generated<Date>;
102
99
  }
103
100
  interface SessionsTable {
@@ -123,6 +120,7 @@ interface MagicLinksTable {
123
120
  }
124
121
  interface UsageDailyTable {
125
122
  account_id: string;
123
+ tenant_id: string | null;
126
124
  date: string;
127
125
  api_requests: Generated<number>;
128
126
  deliveries: Generated<number>;
@@ -249,83 +247,6 @@ interface ChatMessagesTable {
249
247
  metadata: unknown | null;
250
248
  created_at: Generated<Date>;
251
249
  }
252
- interface WorkflowDefinitionsTable {
253
- id: Generated<string>;
254
- name: string;
255
- version: Generated<string>;
256
- status: Generated<string>;
257
- trigger_type: string;
258
- trigger_config: unknown;
259
- handler_path: string;
260
- source_code: string | null;
261
- retries_config: unknown | null;
262
- timeout_ms: number | null;
263
- api_key_id: string;
264
- project_id: string | null;
265
- created_at: Generated<Date>;
266
- updated_at: Generated<Date>;
267
- }
268
- interface WorkflowRunsTable {
269
- id: Generated<string>;
270
- definition_id: string;
271
- status: Generated<string>;
272
- trigger_type: string;
273
- trigger_data: unknown | null;
274
- dedup_key: string | null;
275
- error: string | null;
276
- started_at: Date | null;
277
- completed_at: Date | null;
278
- duration_ms: number | null;
279
- total_ai_tokens: Generated<number>;
280
- created_at: Generated<Date>;
281
- }
282
- interface WorkflowStepsTable {
283
- id: Generated<string>;
284
- run_id: string;
285
- step_index: number;
286
- step_id: string;
287
- step_type: string;
288
- status: Generated<string>;
289
- input: unknown | null;
290
- output: unknown | null;
291
- error: string | null;
292
- retry_count: Generated<number>;
293
- ai_tokens_used: Generated<number>;
294
- started_at: Date | null;
295
- completed_at: Date | null;
296
- duration_ms: number | null;
297
- memo_key: string | null;
298
- parent_step_id: string | null;
299
- created_at: Generated<Date>;
300
- }
301
- interface WorkflowQueueTable {
302
- id: Generated<string>;
303
- run_id: string;
304
- status: Generated<string>;
305
- attempts: Generated<number>;
306
- max_attempts: Generated<number>;
307
- scheduled_for: Generated<Date>;
308
- locked_at: Date | null;
309
- locked_by: string | null;
310
- error: string | null;
311
- created_at: Generated<Date>;
312
- completed_at: Date | null;
313
- }
314
- interface WorkflowSchedulesTable {
315
- id: Generated<string>;
316
- definition_id: string;
317
- cron_expr: string;
318
- timezone: Generated<string>;
319
- next_run_at: Date;
320
- last_run_at: Date | null;
321
- enabled: Generated<boolean>;
322
- created_at: Generated<Date>;
323
- }
324
- interface WorkflowCursorsTable {
325
- name: string;
326
- block_height: Generated<number>;
327
- updated_at: Generated<Date>;
328
- }
329
250
  interface Database {
330
251
  blocks: BlocksTable;
331
252
  transactions: TransactionsTable;
@@ -351,17 +272,14 @@ interface Database {
351
272
  team_invitations: TeamInvitationsTable;
352
273
  chat_sessions: ChatSessionsTable;
353
274
  chat_messages: ChatMessagesTable;
354
- workflow_definitions: WorkflowDefinitionsTable;
355
- workflow_runs: WorkflowRunsTable;
356
- workflow_steps: WorkflowStepsTable;
357
- workflow_queue: WorkflowQueueTable;
358
- workflow_schedules: WorkflowSchedulesTable;
359
- workflow_cursors: WorkflowCursorsTable;
360
- workflow_signer_secrets: WorkflowSignerSecretsTable;
361
- workflow_budgets: WorkflowBudgetsTable;
362
275
  tenants: TenantsTable;
363
276
  tenant_usage_monthly: TenantUsageMonthlyTable;
277
+ tenant_compute_addons: TenantComputeAddonsTable;
278
+ account_spend_caps: AccountSpendCapsTable;
364
279
  provisioning_audit_log: ProvisioningAuditLogTable;
280
+ subscriptions: SubscriptionsTable;
281
+ subscription_outbox: SubscriptionOutboxTable;
282
+ subscription_deliveries: SubscriptionDeliveriesTable;
365
283
  }
366
284
  type TenantStatus = "provisioning" | "active" | "suspended" | "error" | "deleted";
367
285
  interface TenantsTable {
@@ -383,9 +301,9 @@ interface TenantsTable {
383
301
  service_key_enc: Buffer;
384
302
  api_url_internal: string;
385
303
  api_url_public: string;
386
- trial_ends_at: Date;
387
304
  suspended_at: Date | null;
388
305
  last_health_check_at: Date | null;
306
+ last_active_at: Generated<Date>;
389
307
  service_gen: Generated<number>;
390
308
  anon_gen: Generated<number>;
391
309
  project_id: string | null;
@@ -403,6 +321,28 @@ interface TenantUsageMonthlyTable {
403
321
  first_at: Generated<Date>;
404
322
  last_at: Generated<Date>;
405
323
  }
324
+ interface TenantComputeAddonsTable {
325
+ id: Generated<string>;
326
+ tenant_id: string;
327
+ memory_mb_delta: Generated<number>;
328
+ cpu_delta: Generated<number | string>;
329
+ storage_mb_delta: Generated<number>;
330
+ effective_from: Generated<Date>;
331
+ effective_until: Date | null;
332
+ stripe_subscription_item_id: string | null;
333
+ created_at: Generated<Date>;
334
+ }
335
+ interface AccountSpendCapsTable {
336
+ account_id: string;
337
+ monthly_cap_cents: number | null;
338
+ compute_cap_cents: number | null;
339
+ storage_cap_cents: number | null;
340
+ ai_cap_cents: number | null;
341
+ alert_threshold_pct: Generated<number>;
342
+ alert_sent_at: Date | null;
343
+ frozen_at: Date | null;
344
+ updated_at: Generated<Date>;
345
+ }
406
346
  type ProvisioningAuditEvent = "provision.start" | "provision.success" | "provision.failure" | "suspend" | "resume" | "resize" | "keys.rotate" | "bastion.key.upload" | "bastion.key.revoke" | "teardown";
407
347
  type ProvisioningAuditStatus = "ok" | "error";
408
348
  interface ProvisioningAuditLogTable {
@@ -417,29 +357,67 @@ interface ProvisioningAuditLogTable {
417
357
  error: string | null;
418
358
  created_at: Generated<Date>;
419
359
  }
420
- interface WorkflowBudgetsTable {
360
+ type SubscriptionStatus = "active" | "paused" | "error";
361
+ type SubscriptionFormat = "standard-webhooks" | "inngest" | "trigger" | "cloudflare" | "cloudevents" | "raw";
362
+ type SubscriptionRuntime = "inngest" | "trigger" | "cloudflare" | "node";
363
+ interface SubscriptionsTable {
421
364
  id: Generated<string>;
422
- workflow_definition_id: string;
423
- /** Period key: "daily:YYYY-MM-DD" | "weekly:YYYY-Www" | "per-run:<uuid>". */
424
- period: string;
425
- ai_usd_used: Generated<string>;
426
- ai_tokens_used: Generated<string>;
427
- chain_microstx_used: Generated<string>;
428
- chain_tx_count: Generated<number>;
429
- run_count: Generated<number>;
430
- step_count: Generated<number>;
431
- reset_at: Date;
365
+ account_id: string;
366
+ project_id: string | null;
367
+ name: string;
368
+ status: ColumnType<SubscriptionStatus, SubscriptionStatus | undefined, SubscriptionStatus>;
369
+ subgraph_name: string;
370
+ table_name: string;
371
+ filter: Generated<unknown>;
372
+ format: ColumnType<SubscriptionFormat, SubscriptionFormat | undefined, SubscriptionFormat>;
373
+ runtime: SubscriptionRuntime | null;
374
+ url: string;
375
+ signing_secret_enc: Buffer;
376
+ auth_config: Generated<unknown>;
377
+ max_retries: Generated<number>;
378
+ timeout_ms: Generated<number>;
379
+ concurrency: Generated<number>;
380
+ circuit_failures: Generated<number>;
381
+ circuit_opened_at: Date | null;
382
+ last_delivery_at: Date | null;
383
+ last_success_at: Date | null;
384
+ last_error: string | null;
432
385
  created_at: Generated<Date>;
433
386
  updated_at: Generated<Date>;
434
387
  }
435
- interface WorkflowSignerSecretsTable {
388
+ type OutboxStatus = "pending" | "delivered" | "dead";
389
+ interface SubscriptionOutboxTable {
436
390
  id: Generated<string>;
437
- account_id: string;
438
- name: string;
439
- /** AES-GCM ciphertext bytes produced by the runner's KMS on write. */
440
- encrypted_value: Buffer;
391
+ subscription_id: string;
392
+ subgraph_name: string;
393
+ table_name: string;
394
+ block_height: number | bigint;
395
+ tx_id: string | null;
396
+ row_pk: unknown;
397
+ event_type: string;
398
+ payload: unknown;
399
+ dedup_key: string;
400
+ attempt: Generated<number>;
401
+ next_attempt_at: Generated<Date>;
402
+ status: ColumnType<OutboxStatus, OutboxStatus | undefined, OutboxStatus>;
403
+ is_replay: Generated<boolean>;
404
+ delivered_at: Date | null;
405
+ failed_at: Date | null;
406
+ locked_by: string | null;
407
+ locked_until: Date | null;
441
408
  created_at: Generated<Date>;
442
- updated_at: Generated<Date>;
409
+ }
410
+ interface SubscriptionDeliveriesTable {
411
+ id: Generated<string>;
412
+ outbox_id: string;
413
+ subscription_id: string;
414
+ attempt: number;
415
+ status_code: number | null;
416
+ response_headers: unknown | null;
417
+ response_body: string | null;
418
+ error_message: string | null;
419
+ duration_ms: number | null;
420
+ dispatched_at: Generated<Date>;
443
421
  }
444
422
  /** Matches the NewBlockPayload shape expected by the indexer's /new_block endpoint */
445
423
  interface ReplayBlockPayload {
@@ -25,4 +25,23 @@ interface TokenUsage {
25
25
  * display "-" rather than "$0".
26
26
  */
27
27
  declare function computeUsdCost(provider: string, modelId: string, usage: TokenUsage): number | null;
28
- export { computeUsdCost, TokenUsage, ModelPricing, MODEL_PRICING };
28
+ interface AiCap {
29
+ /** Max step.ai / generateText / generateObject calls per UTC day. */
30
+ evalsPerDay: number;
31
+ /** Stripe meter events emitted for overage past this cap. */
32
+ overageMeterEventName: "ai_evals";
33
+ }
34
+ /** Resolve the daily AI eval cap for a plan. Unknown plans get Hobby's
35
+ * cap so a stray DB value can't accidentally become unlimited. */
36
+ declare function getAiCapForPlan(plan: string): AiCap;
37
+ /** Included compute hours per billing period. Unknown → hobby. */
38
+ declare function getComputeAllowanceHours(plan: string): number;
39
+ /** Included storage bytes. Unknown → hobby. */
40
+ declare function getStorageAllowanceBytes(plan: string): number;
41
+ /** Whether this plan bills storage overage. Hobby has a hard cap
42
+ * (no overage billing); paid tiers bill $2/GB over allowance. */
43
+ declare function hasStorageOverage(plan: string): boolean;
44
+ declare function getBasePriceCents(plan: string): number;
45
+ /** Capitalized display name for a plan tier. */
46
+ declare function getPlanDisplayName(plan: string): string;
47
+ export { hasStorageOverage, getStorageAllowanceBytes, getPlanDisplayName, getComputeAllowanceHours, getBasePriceCents, getAiCapForPlan, computeUsdCost, TokenUsage, ModelPricing, MODEL_PRICING, AiCap };
@@ -38,10 +38,67 @@ function computeUsdCost(provider, modelId, usage) {
38
38
  return null;
39
39
  return usage.inputTokens * p.inputPerMTokens / 1e6 + usage.outputTokens * p.outputPerMTokens / 1e6;
40
40
  }
41
+ var AI_CAP_UNLIMITED = {
42
+ evalsPerDay: Number.POSITIVE_INFINITY,
43
+ overageMeterEventName: "ai_evals"
44
+ };
45
+ var AI_CAPS_BY_PLAN = {
46
+ hobby: { evalsPerDay: 50, overageMeterEventName: "ai_evals" },
47
+ launch: { evalsPerDay: 500, overageMeterEventName: "ai_evals" },
48
+ grow: { evalsPerDay: 1000, overageMeterEventName: "ai_evals" },
49
+ scale: { evalsPerDay: 2500, overageMeterEventName: "ai_evals" },
50
+ enterprise: AI_CAP_UNLIMITED
51
+ };
52
+ function getAiCapForPlan(plan) {
53
+ return AI_CAPS_BY_PLAN[plan] ?? AI_CAPS_BY_PLAN.hobby;
54
+ }
55
+ var BYTES_PER_GB = 1024 ** 3;
56
+ var COMPUTE_ALLOWANCE_BY_PLAN = {
57
+ hobby: Number.POSITIVE_INFINITY,
58
+ launch: 500,
59
+ grow: 1000,
60
+ scale: 2500,
61
+ enterprise: Number.POSITIVE_INFINITY
62
+ };
63
+ var STORAGE_ALLOWANCE_BYTES_BY_PLAN = {
64
+ hobby: 5 * BYTES_PER_GB,
65
+ launch: 50 * BYTES_PER_GB,
66
+ grow: 200 * BYTES_PER_GB,
67
+ scale: 1000 * BYTES_PER_GB,
68
+ enterprise: Number.POSITIVE_INFINITY
69
+ };
70
+ function getComputeAllowanceHours(plan) {
71
+ return COMPUTE_ALLOWANCE_BY_PLAN[plan] ?? COMPUTE_ALLOWANCE_BY_PLAN.hobby;
72
+ }
73
+ function getStorageAllowanceBytes(plan) {
74
+ return STORAGE_ALLOWANCE_BYTES_BY_PLAN[plan] ?? STORAGE_ALLOWANCE_BYTES_BY_PLAN.hobby;
75
+ }
76
+ function hasStorageOverage(plan) {
77
+ return plan !== "hobby";
78
+ }
79
+ var BASE_PRICE_CENTS_BY_PLAN = {
80
+ hobby: 0,
81
+ launch: 14900,
82
+ grow: 34900,
83
+ scale: 79900,
84
+ enterprise: 0
85
+ };
86
+ function getBasePriceCents(plan) {
87
+ return BASE_PRICE_CENTS_BY_PLAN[plan] ?? 0;
88
+ }
89
+ function getPlanDisplayName(plan) {
90
+ return plan.charAt(0).toUpperCase() + plan.slice(1);
91
+ }
41
92
  export {
93
+ hasStorageOverage,
94
+ getStorageAllowanceBytes,
95
+ getPlanDisplayName,
96
+ getComputeAllowanceHours,
97
+ getBasePriceCents,
98
+ getAiCapForPlan,
42
99
  computeUsdCost,
43
100
  MODEL_PRICING
44
101
  };
45
102
 
46
- //# debugId=4DD95AFC388E4B9664756E2164756E21
103
+ //# debugId=E4067B99B07E03FD64756E2164756E21
47
104
  //# sourceMappingURL=pricing.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/pricing.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * Token → USD pricing constants for internal observability.\n *\n * **Not customer billing.** Tier pricing covers compute; these constants\n * let the dashboard display \"~$X spent in AI today\" and let us track\n * gross-margin internally. Update manually when providers change prices\n * (roughly twice per year).\n *\n * Source: public provider pricing pages as of 2026-04-17.\n */\n\nexport interface ModelPricing {\n\t/** USD per 1M input tokens */\n\tinputPerMTokens: number;\n\t/** USD per 1M output tokens */\n\toutputPerMTokens: number;\n}\n\nexport const MODEL_PRICING: Record<string, Record<string, ModelPricing>> = {\n\tanthropic: {\n\t\t\"claude-haiku-4-5\": { inputPerMTokens: 1, outputPerMTokens: 5 },\n\t\t\"claude-haiku-4-5-20251001\": { inputPerMTokens: 1, outputPerMTokens: 5 },\n\t\t\"claude-sonnet-4-6\": { inputPerMTokens: 3, outputPerMTokens: 15 },\n\t\t\"claude-opus-4-7\": { inputPerMTokens: 15, outputPerMTokens: 75 },\n\t},\n\topenai: {\n\t\t\"gpt-4.1\": { inputPerMTokens: 2.5, outputPerMTokens: 10 },\n\t\t\"gpt-4o\": { inputPerMTokens: 2.5, outputPerMTokens: 10 },\n\t\t\"gpt-4o-mini\": { inputPerMTokens: 0.15, outputPerMTokens: 0.6 },\n\t},\n\tgoogle: {\n\t\t\"gemini-2.5-pro\": { inputPerMTokens: 1.25, outputPerMTokens: 10 },\n\t\t\"gemini-2.5-flash\": { inputPerMTokens: 0.3, outputPerMTokens: 2.5 },\n\t},\n};\n\nexport interface TokenUsage {\n\tinputTokens: number;\n\toutputTokens: number;\n}\n\n/**\n * Compute approximate USD cost for an AI step. Returns `null` if the\n * (provider, model) pair isn't in the pricing table — the caller should\n * display \"-\" rather than \"$0\".\n */\nexport function computeUsdCost(\n\tprovider: string,\n\tmodelId: string,\n\tusage: TokenUsage,\n): number | null {\n\tconst p = MODEL_PRICING[provider]?.[modelId];\n\tif (!p) return null;\n\treturn (\n\t\t(usage.inputTokens * p.inputPerMTokens) / 1_000_000 +\n\t\t(usage.outputTokens * p.outputPerMTokens) / 1_000_000\n\t);\n}\n"
5
+ "/**\n * Token → USD pricing constants for internal observability.\n *\n * **Not customer billing.** Tier pricing covers compute; these constants\n * let the dashboard display \"~$X spent in AI today\" and let us track\n * gross-margin internally. Update manually when providers change prices\n * (roughly twice per year).\n *\n * Source: public provider pricing pages as of 2026-04-17.\n */\n\nexport interface ModelPricing {\n\t/** USD per 1M input tokens */\n\tinputPerMTokens: number;\n\t/** USD per 1M output tokens */\n\toutputPerMTokens: number;\n}\n\nexport const MODEL_PRICING: Record<string, Record<string, ModelPricing>> = {\n\tanthropic: {\n\t\t\"claude-haiku-4-5\": { inputPerMTokens: 1, outputPerMTokens: 5 },\n\t\t\"claude-haiku-4-5-20251001\": { inputPerMTokens: 1, outputPerMTokens: 5 },\n\t\t\"claude-sonnet-4-6\": { inputPerMTokens: 3, outputPerMTokens: 15 },\n\t\t\"claude-opus-4-7\": { inputPerMTokens: 15, outputPerMTokens: 75 },\n\t},\n\topenai: {\n\t\t\"gpt-4.1\": { inputPerMTokens: 2.5, outputPerMTokens: 10 },\n\t\t\"gpt-4o\": { inputPerMTokens: 2.5, outputPerMTokens: 10 },\n\t\t\"gpt-4o-mini\": { inputPerMTokens: 0.15, outputPerMTokens: 0.6 },\n\t},\n\tgoogle: {\n\t\t\"gemini-2.5-pro\": { inputPerMTokens: 1.25, outputPerMTokens: 10 },\n\t\t\"gemini-2.5-flash\": { inputPerMTokens: 0.3, outputPerMTokens: 2.5 },\n\t},\n};\n\nexport interface TokenUsage {\n\tinputTokens: number;\n\toutputTokens: number;\n}\n\n/**\n * Compute approximate USD cost for an AI step. Returns `null` if the\n * (provider, model) pair isn't in the pricing table — the caller should\n * display \"-\" rather than \"$0\".\n */\nexport function computeUsdCost(\n\tprovider: string,\n\tmodelId: string,\n\tusage: TokenUsage,\n): number | null {\n\tconst p = MODEL_PRICING[provider]?.[modelId];\n\tif (!p) return null;\n\treturn (\n\t\t(usage.inputTokens * p.inputPerMTokens) / 1_000_000 +\n\t\t(usage.outputTokens * p.outputPerMTokens) / 1_000_000\n\t);\n}\n\n// ── Per-tier AI eval caps (workflow-runner) ───────────────────────────\n//\n// Daily `step.ai` budget by tier. Hit the cap → runner throws\n// AI_CAP_REACHED and the step fails cleanly so condition-only paths\n// continue. Overage (past cap) bills to the `ai_evals` Stripe meter on\n// paid tiers.\n//\n// Caps set conservatively so Pro Micro stays margin-positive at\n// realistic AI utilization. Raise based on data, not aspiration.\n\nexport interface AiCap {\n\t/** Max step.ai / generateText / generateObject calls per UTC day. */\n\tevalsPerDay: number;\n\t/** Stripe meter events emitted for overage past this cap. */\n\toverageMeterEventName: \"ai_evals\";\n}\n\nconst AI_CAP_UNLIMITED: AiCap = {\n\tevalsPerDay: Number.POSITIVE_INFINITY,\n\toverageMeterEventName: \"ai_evals\",\n};\n\nconst AI_CAPS_BY_PLAN: Record<string, AiCap> = {\n\thobby: { evalsPerDay: 50, overageMeterEventName: \"ai_evals\" },\n\tlaunch: { evalsPerDay: 500, overageMeterEventName: \"ai_evals\" },\n\tgrow: { evalsPerDay: 1000, overageMeterEventName: \"ai_evals\" },\n\tscale: { evalsPerDay: 2500, overageMeterEventName: \"ai_evals\" },\n\tenterprise: AI_CAP_UNLIMITED,\n};\n\n/** Resolve the daily AI eval cap for a plan. Unknown plans get Hobby's\n * cap so a stray DB value can't accidentally become unlimited. */\nexport function getAiCapForPlan(plan: string): AiCap {\n\treturn AI_CAPS_BY_PLAN[plan] ?? AI_CAPS_BY_PLAN.hobby;\n}\n\n// ── Per-tier compute + storage allowances (usage page) ──────────────\n//\n// \"Included\" amounts per billing period. Actual billing comes from\n// Stripe compute-hours + storage-overage meters; these constants power\n// the dashboard display and the approximation used by `/api/accounts/usage`.\n//\n// Values picked to match `project_supabase_pricing_model.md`:\n// Hobby — 50 h / 5 GB (Nano — free tier)\n// Launch — 500 h / 50 GB ($149/mo)\n// Grow — 1,000 h / 200 GB ($349/mo)\n// Scale — 2,500 h / 1 TB ($799/mo)\n// Enterprise — ∞ / ∞\n\nconst BYTES_PER_GB = 1024 ** 3;\n\n// Hobby has no compute-hour cap — auto-pause after 7d idle is the cap.\n// Paid tiers bill Stripe-metered hours past the included credit; the\n// hour equivalents below are approximate display values. Real billing\n// uses the `compute_hours` meter in Stripe, not these numbers.\nconst COMPUTE_ALLOWANCE_BY_PLAN: Record<string, number> = {\n\thobby: Number.POSITIVE_INFINITY,\n\tlaunch: 500,\n\tgrow: 1000,\n\tscale: 2500,\n\tenterprise: Number.POSITIVE_INFINITY,\n};\n\nconst STORAGE_ALLOWANCE_BYTES_BY_PLAN: Record<string, number> = {\n\thobby: 5 * BYTES_PER_GB,\n\tlaunch: 50 * BYTES_PER_GB,\n\tgrow: 200 * BYTES_PER_GB,\n\tscale: 1000 * BYTES_PER_GB,\n\tenterprise: Number.POSITIVE_INFINITY,\n};\n\n/** Included compute hours per billing period. Unknown → hobby. */\nexport function getComputeAllowanceHours(plan: string): number {\n\treturn COMPUTE_ALLOWANCE_BY_PLAN[plan] ?? COMPUTE_ALLOWANCE_BY_PLAN.hobby;\n}\n\n/** Included storage bytes. Unknown → hobby. */\nexport function getStorageAllowanceBytes(plan: string): number {\n\treturn (\n\t\tSTORAGE_ALLOWANCE_BYTES_BY_PLAN[plan] ??\n\t\tSTORAGE_ALLOWANCE_BYTES_BY_PLAN.hobby\n\t);\n}\n\n/** Whether this plan bills storage overage. Hobby has a hard cap\n * (no overage billing); paid tiers bill $2/GB over allowance. */\nexport function hasStorageOverage(plan: string): boolean {\n\treturn plan !== \"hobby\";\n}\n\n/** Base monthly price for a plan, in cents. Enterprise is custom → 0. */\nconst BASE_PRICE_CENTS_BY_PLAN: Record<string, number> = {\n\thobby: 0,\n\tlaunch: 14900,\n\tgrow: 34900,\n\tscale: 79900,\n\tenterprise: 0,\n};\n\nexport function getBasePriceCents(plan: string): number {\n\treturn BASE_PRICE_CENTS_BY_PLAN[plan] ?? 0;\n}\n\n/** Capitalized display name for a plan tier. */\nexport function getPlanDisplayName(plan: string): string {\n\treturn plan.charAt(0).toUpperCase() + plan.slice(1);\n}\n"
6
6
  ],
7
- "mappings": ";;;;;;;;;;;;;;;;;AAkBO,IAAM,gBAA8D;AAAA,EAC1E,WAAW;AAAA,IACV,oBAAoB,EAAE,iBAAiB,GAAG,kBAAkB,EAAE;AAAA,IAC9D,6BAA6B,EAAE,iBAAiB,GAAG,kBAAkB,EAAE;AAAA,IACvE,qBAAqB,EAAE,iBAAiB,GAAG,kBAAkB,GAAG;AAAA,IAChE,mBAAmB,EAAE,iBAAiB,IAAI,kBAAkB,GAAG;AAAA,EAChE;AAAA,EACA,QAAQ;AAAA,IACP,WAAW,EAAE,iBAAiB,KAAK,kBAAkB,GAAG;AAAA,IACxD,UAAU,EAAE,iBAAiB,KAAK,kBAAkB,GAAG;AAAA,IACvD,eAAe,EAAE,iBAAiB,MAAM,kBAAkB,IAAI;AAAA,EAC/D;AAAA,EACA,QAAQ;AAAA,IACP,kBAAkB,EAAE,iBAAiB,MAAM,kBAAkB,GAAG;AAAA,IAChE,oBAAoB,EAAE,iBAAiB,KAAK,kBAAkB,IAAI;AAAA,EACnE;AACD;AAYO,SAAS,cAAc,CAC7B,UACA,SACA,OACgB;AAAA,EAChB,MAAM,IAAI,cAAc,YAAY;AAAA,EACpC,IAAI,CAAC;AAAA,IAAG,OAAO;AAAA,EACf,OACE,MAAM,cAAc,EAAE,kBAAmB,MACzC,MAAM,eAAe,EAAE,mBAAoB;AAAA;",
8
- "debugId": "4DD95AFC388E4B9664756E2164756E21",
7
+ "mappings": ";;;;;;;;;;;;;;;;;AAkBO,IAAM,gBAA8D;AAAA,EAC1E,WAAW;AAAA,IACV,oBAAoB,EAAE,iBAAiB,GAAG,kBAAkB,EAAE;AAAA,IAC9D,6BAA6B,EAAE,iBAAiB,GAAG,kBAAkB,EAAE;AAAA,IACvE,qBAAqB,EAAE,iBAAiB,GAAG,kBAAkB,GAAG;AAAA,IAChE,mBAAmB,EAAE,iBAAiB,IAAI,kBAAkB,GAAG;AAAA,EAChE;AAAA,EACA,QAAQ;AAAA,IACP,WAAW,EAAE,iBAAiB,KAAK,kBAAkB,GAAG;AAAA,IACxD,UAAU,EAAE,iBAAiB,KAAK,kBAAkB,GAAG;AAAA,IACvD,eAAe,EAAE,iBAAiB,MAAM,kBAAkB,IAAI;AAAA,EAC/D;AAAA,EACA,QAAQ;AAAA,IACP,kBAAkB,EAAE,iBAAiB,MAAM,kBAAkB,GAAG;AAAA,IAChE,oBAAoB,EAAE,iBAAiB,KAAK,kBAAkB,IAAI;AAAA,EACnE;AACD;AAYO,SAAS,cAAc,CAC7B,UACA,SACA,OACgB;AAAA,EAChB,MAAM,IAAI,cAAc,YAAY;AAAA,EACpC,IAAI,CAAC;AAAA,IAAG,OAAO;AAAA,EACf,OACE,MAAM,cAAc,EAAE,kBAAmB,MACzC,MAAM,eAAe,EAAE,mBAAoB;AAAA;AAqB9C,IAAM,mBAA0B;AAAA,EAC/B,aAAa,OAAO;AAAA,EACpB,uBAAuB;AACxB;AAEA,IAAM,kBAAyC;AAAA,EAC9C,OAAO,EAAE,aAAa,IAAI,uBAAuB,WAAW;AAAA,EAC5D,QAAQ,EAAE,aAAa,KAAK,uBAAuB,WAAW;AAAA,EAC9D,MAAM,EAAE,aAAa,MAAM,uBAAuB,WAAW;AAAA,EAC7D,OAAO,EAAE,aAAa,MAAM,uBAAuB,WAAW;AAAA,EAC9D,YAAY;AACb;AAIO,SAAS,eAAe,CAAC,MAAqB;AAAA,EACpD,OAAO,gBAAgB,SAAS,gBAAgB;AAAA;AAgBjD,IAAM,eAAe,QAAQ;AAM7B,IAAM,4BAAoD;AAAA,EACzD,OAAO,OAAO;AAAA,EACd,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,OAAO;AAAA,EACP,YAAY,OAAO;AACpB;AAEA,IAAM,kCAA0D;AAAA,EAC/D,OAAO,IAAI;AAAA,EACX,QAAQ,KAAK;AAAA,EACb,MAAM,MAAM;AAAA,EACZ,OAAO,OAAO;AAAA,EACd,YAAY,OAAO;AACpB;AAGO,SAAS,wBAAwB,CAAC,MAAsB;AAAA,EAC9D,OAAO,0BAA0B,SAAS,0BAA0B;AAAA;AAI9D,SAAS,wBAAwB,CAAC,MAAsB;AAAA,EAC9D,OACC,gCAAgC,SAChC,gCAAgC;AAAA;AAM3B,SAAS,iBAAiB,CAAC,MAAuB;AAAA,EACxD,OAAO,SAAS;AAAA;AAIjB,IAAM,2BAAmD;AAAA,EACxD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,OAAO;AAAA,EACP,YAAY;AACb;AAEO,SAAS,iBAAiB,CAAC,MAAsB;AAAA,EACvD,OAAO,yBAAyB,SAAS;AAAA;AAInC,SAAS,kBAAkB,CAAC,MAAsB;AAAA,EACxD,OAAO,KAAK,OAAO,CAAC,EAAE,YAAY,IAAI,KAAK,MAAM,CAAC;AAAA;",
8
+ "debugId": "E4067B99B07E03FD64756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -0,0 +1,47 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Drop the marketplace-era columns from `subgraphs`. The marketplace
5
+ * feature is gone (see `0022_marketplace`); these columns were only
6
+ * written by the deleted publish/unpublish endpoints and read by the
7
+ * deleted marketplace browse routes.
8
+ *
9
+ * Production note: the platform DB's `subgraphs` table was manually dropped
10
+ * after migration `0041` as part of the shared→dedicated cutover (see the
11
+ * note there). This migration must tolerate that — it runs fine on OSS
12
+ * deployments and fresh dev DBs where the table still exists, and no-ops
13
+ * on production where the table is already gone.
14
+ *
15
+ * Indexes `subgraphs_is_public_idx` and `subgraphs_tags_idx` from 0022
16
+ * drop automatically with the columns.
17
+ */
18
+ export async function up(db: Kysely<unknown>): Promise<void> {
19
+ await sql`SET lock_timeout = '30s'`.execute(db);
20
+ await sql`
21
+ DO $$
22
+ BEGIN
23
+ IF to_regclass('public.subgraphs') IS NOT NULL THEN
24
+ ALTER TABLE subgraphs DROP COLUMN IF EXISTS forked_from_id;
25
+ ALTER TABLE subgraphs DROP COLUMN IF EXISTS description;
26
+ ALTER TABLE subgraphs DROP COLUMN IF EXISTS tags;
27
+ ALTER TABLE subgraphs DROP COLUMN IF EXISTS is_public;
28
+ END IF;
29
+ END$$
30
+ `.execute(db);
31
+ }
32
+
33
+ export async function down(db: Kysely<unknown>): Promise<void> {
34
+ // Re-adding these columns would resurrect dead feature state; only
35
+ // restore them where the table exists, to match the up() guard.
36
+ await sql`
37
+ DO $$
38
+ BEGIN
39
+ IF to_regclass('public.subgraphs') IS NOT NULL THEN
40
+ ALTER TABLE subgraphs ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
41
+ ALTER TABLE subgraphs ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}';
42
+ ALTER TABLE subgraphs ADD COLUMN IF NOT EXISTS description text;
43
+ ALTER TABLE subgraphs ADD COLUMN IF NOT EXISTS forked_from_id uuid REFERENCES subgraphs(id) ON DELETE SET NULL;
44
+ END IF;
45
+ END$$
46
+ `.execute(db);
47
+ }
@@ -0,0 +1,47 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Switch tenant suspension from time-based (14-day trial) to activity-based
5
+ * (Supabase-style auto-pause on the Hobby tier).
6
+ *
7
+ * - Drop `tenants.trial_ends_at` + its supporting index. The trial model
8
+ * is gone; no grandfathering (pre-launch, zero external users).
9
+ * - Add `tenants.last_active_at timestamptz NOT NULL DEFAULT now()`. Bumped
10
+ * by tenant API middleware on 2xx responses + workflow-runner on run
11
+ * start. The new `tenant-idle-pause` cron suspends Hobby tenants idle
12
+ * beyond a threshold.
13
+ * - Add `tenants_last_active_idx` for the cron's WHERE clause (plan +
14
+ * last_active_at).
15
+ */
16
+ export async function up(db: Kysely<unknown>): Promise<void> {
17
+ await sql`SET lock_timeout = '30s'`.execute(db);
18
+ await sql`DROP INDEX IF EXISTS tenants_trial_ends_idx`.execute(db);
19
+ await sql`ALTER TABLE tenants DROP COLUMN IF EXISTS trial_ends_at`.execute(
20
+ db,
21
+ );
22
+ await sql`
23
+ ALTER TABLE tenants
24
+ ADD COLUMN last_active_at timestamptz NOT NULL DEFAULT now()
25
+ `.execute(db);
26
+ await sql`
27
+ CREATE INDEX tenants_last_active_idx
28
+ ON tenants (plan, last_active_at)
29
+ WHERE status = 'active'
30
+ `.execute(db);
31
+ }
32
+
33
+ export async function down(db: Kysely<unknown>): Promise<void> {
34
+ await sql`DROP INDEX IF EXISTS tenants_last_active_idx`.execute(db);
35
+ await sql`ALTER TABLE tenants DROP COLUMN IF EXISTS last_active_at`.execute(
36
+ db,
37
+ );
38
+ await sql`
39
+ ALTER TABLE tenants
40
+ ADD COLUMN trial_ends_at timestamptz NOT NULL DEFAULT (now() + interval '14 days')
41
+ `.execute(db);
42
+ await sql`
43
+ CREATE INDEX tenants_trial_ends_idx
44
+ ON tenants (trial_ends_at)
45
+ WHERE status IN ('provisioning', 'active')
46
+ `.execute(db);
47
+ }
@@ -0,0 +1,73 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Tenant-scope the `usage_daily` table so Stripe metering (Sprint C) can
5
+ * bill per-tenant, not per-account. Account-level billing worked when
6
+ * accounts were 1:1 with tenants; once we adopt org-level billing where
7
+ * an account owns multiple projects (each = one tenant), we need the
8
+ * tenant dimension.
9
+ *
10
+ * - `tenant_id` added nullable — existing rows predate the column.
11
+ * Backfill is best-effort: rows where the account has exactly one
12
+ * tenant get that tenant's id; ambiguous rows stay NULL.
13
+ * - Unique constraint relaxed from `(account_id, date)` to
14
+ * `(account_id, tenant_id, date)` (NULLs treated as distinct per
15
+ * Postgres default) so future per-tenant rows don't collide with
16
+ * account-level history.
17
+ */
18
+ export async function up(db: Kysely<unknown>): Promise<void> {
19
+ await sql`SET lock_timeout = '30s'`.execute(db);
20
+
21
+ await sql`
22
+ ALTER TABLE usage_daily
23
+ ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL
24
+ `.execute(db);
25
+
26
+ // Best-effort backfill — only fill rows where the account has a single
27
+ // tenant. Ambiguous accounts (multi-tenant) stay NULL; Sprint C starts
28
+ // writing tenant_id on every new row.
29
+ await sql`
30
+ UPDATE usage_daily u
31
+ SET tenant_id = t.id
32
+ FROM tenants t
33
+ WHERE u.tenant_id IS NULL
34
+ AND t.account_id = u.account_id
35
+ AND t.status <> 'deleted'
36
+ AND NOT EXISTS (
37
+ SELECT 1 FROM tenants t2
38
+ WHERE t2.account_id = u.account_id
39
+ AND t2.id <> t.id
40
+ AND t2.status <> 'deleted'
41
+ )
42
+ `.execute(db);
43
+
44
+ // Drop old PK/unique so we can widen to include tenant_id.
45
+ await sql`
46
+ ALTER TABLE usage_daily DROP CONSTRAINT IF EXISTS usage_daily_pkey
47
+ `.execute(db);
48
+ await sql`
49
+ ALTER TABLE usage_daily
50
+ ADD CONSTRAINT usage_daily_pkey
51
+ UNIQUE (account_id, tenant_id, date)
52
+ `.execute(db);
53
+
54
+ await sql`
55
+ CREATE INDEX IF NOT EXISTS usage_daily_tenant_date_idx
56
+ ON usage_daily (tenant_id, date DESC)
57
+ `.execute(db);
58
+ }
59
+
60
+ export async function down(db: Kysely<unknown>): Promise<void> {
61
+ await sql`DROP INDEX IF EXISTS usage_daily_tenant_date_idx`.execute(db);
62
+ await sql`
63
+ ALTER TABLE usage_daily DROP CONSTRAINT IF EXISTS usage_daily_pkey
64
+ `.execute(db);
65
+ await sql`
66
+ ALTER TABLE usage_daily
67
+ ADD CONSTRAINT usage_daily_pkey
68
+ PRIMARY KEY (account_id, date)
69
+ `.execute(db);
70
+ await sql`ALTER TABLE usage_daily DROP COLUMN IF EXISTS tenant_id`.execute(
71
+ db,
72
+ );
73
+ }
@@ -0,0 +1,49 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Compute add-ons on top of a plan's base spec.
5
+ *
6
+ * The two-axis model (plan × compute) needs somewhere to record "Pro +
7
+ * 4 GB RAM bundle" without mutating the plan. Each row is one add-on;
8
+ * SUM() over active rows for a tenant gives the delta to apply on top
9
+ * of `plans.ts` base compute.
10
+ *
11
+ * Columns kept as *_delta so "zero add-ons" is the identity (no
12
+ * nullables in the sum path). Effective compute = plan base +
13
+ * SUM(active deltas).
14
+ *
15
+ * `effective_until` nullable — open-ended add-on (active until cancelled).
16
+ * Ranges allow future-dated add-ons + mid-cycle cancels cleanly.
17
+ *
18
+ * `stripe_subscription_item_id` nullable — present once Sprint C.2/C.3
19
+ * wire Stripe. For Sprint C.1 the table exists but nothing writes it.
20
+ */
21
+ export async function up(db: Kysely<unknown>): Promise<void> {
22
+ await sql`
23
+ CREATE TABLE tenant_compute_addons (
24
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25
+ tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
26
+ memory_mb_delta integer NOT NULL DEFAULT 0,
27
+ cpu_delta numeric(4,2) NOT NULL DEFAULT 0,
28
+ storage_mb_delta integer NOT NULL DEFAULT 0,
29
+ effective_from timestamptz NOT NULL DEFAULT now(),
30
+ effective_until timestamptz,
31
+ stripe_subscription_item_id text,
32
+ created_at timestamptz NOT NULL DEFAULT now()
33
+ )
34
+ `.execute(db);
35
+
36
+ // Partial index limited to open-ended add-ons (the common case) —
37
+ // `now()` isn't immutable so it can't appear in a WHERE clause here.
38
+ // Closed-end add-ons still get served from a seq scan + date filter,
39
+ // which is cheap at our row counts.
40
+ await sql`
41
+ CREATE INDEX tenant_compute_addons_tenant_idx
42
+ ON tenant_compute_addons (tenant_id)
43
+ WHERE effective_until IS NULL
44
+ `.execute(db);
45
+ }
46
+
47
+ export async function down(db: Kysely<unknown>): Promise<void> {
48
+ await sql`DROP TABLE IF EXISTS tenant_compute_addons`.execute(db);
49
+ }
@@ -0,0 +1,30 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Link accounts to Stripe customers.
5
+ *
6
+ * Nullable because we create the Stripe customer lazily — only when an
7
+ * account upgrades past Hobby. Hobby users never show up in Stripe, which
8
+ * keeps the dashboard clean and avoids Stripe-side overhead per free user.
9
+ *
10
+ * Unique index (not constraint) so the column stays nullable but still
11
+ * enforces one-to-one when set.
12
+ */
13
+ export async function up(db: Kysely<unknown>): Promise<void> {
14
+ await sql`
15
+ ALTER TABLE accounts
16
+ ADD COLUMN IF NOT EXISTS stripe_customer_id text
17
+ `.execute(db);
18
+ await sql`
19
+ CREATE UNIQUE INDEX IF NOT EXISTS accounts_stripe_customer_idx
20
+ ON accounts (stripe_customer_id)
21
+ WHERE stripe_customer_id IS NOT NULL
22
+ `.execute(db);
23
+ }
24
+
25
+ export async function down(db: Kysely<unknown>): Promise<void> {
26
+ await sql`DROP INDEX IF EXISTS accounts_stripe_customer_idx`.execute(db);
27
+ await sql`
28
+ ALTER TABLE accounts DROP COLUMN IF EXISTS stripe_customer_id
29
+ `.execute(db);
30
+ }
@@ -0,0 +1,45 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * Per-account spending caps + threshold alert state.
5
+ *
6
+ * This is the anti-Supabase-#1-complaint differentiator: soft caps with
7
+ * 80% threshold email alerts, per-line sub-caps, and a clear "frozen"
8
+ * state the user can unfreeze by raising the cap (instead of Supabase's
9
+ * binary cap that blocks certain line items but not compute, producing
10
+ * surprise bills).
11
+ *
12
+ * One row per account (account_id is PK). Null caps mean "no cap" for
13
+ * that line. `frozen_at` is set by the metering worker when a cap is
14
+ * hit; cleared on the next billing cycle's `invoice.paid` webhook. While
15
+ * frozen, meter events stop accumulating for that account.
16
+ *
17
+ * `alert_threshold_pct` default 80 — the Supabase request that's been
18
+ * open since 2023.
19
+ */
20
+ export async function up(db: Kysely<unknown>): Promise<void> {
21
+ await sql`
22
+ CREATE TABLE account_spend_caps (
23
+ account_id uuid PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
24
+ monthly_cap_cents integer,
25
+ compute_cap_cents integer,
26
+ storage_cap_cents integer,
27
+ ai_cap_cents integer,
28
+ alert_threshold_pct integer NOT NULL DEFAULT 80,
29
+ alert_sent_at timestamptz,
30
+ frozen_at timestamptz,
31
+ updated_at timestamptz NOT NULL DEFAULT now()
32
+ )
33
+ `.execute(db);
34
+
35
+ // Fast lookup for the metering crons: "is this account currently frozen?"
36
+ await sql`
37
+ CREATE INDEX account_spend_caps_frozen_idx
38
+ ON account_spend_caps (account_id)
39
+ WHERE frozen_at IS NOT NULL
40
+ `.execute(db);
41
+ }
42
+
43
+ export async function down(db: Kysely<unknown>): Promise<void> {
44
+ await sql`DROP TABLE IF EXISTS account_spend_caps`.execute(db);
45
+ }